LangChain and Provider SDKs
Connect model-driven work to Chio’s Rust kernel. Keep the application’s tool authority in one host as you change frameworks or model providers.
On this page
Build on an application you control
Give an agent access to your notes API, with a signed grant for two writes. Start by saving your own text through a real LangChain tool. Then connect OpenAI Chat Completions or Anthropic Messages to the same MCP host, so model-selected calls meet the same authority boundary.
The downloads include the API, kernel host, capability issuance, client transport and complete model loops. You do not need to provision a separate hosted edge or trust service for this project. The first LangChain run needs no model account.
Save notes through LangChain
Prepare the host and run your tool
Download the complete agent-notes project and extract it anywhere. It includes the notes API, Rust host, Python clients and their SDK source. Install Rust 1.94.1 and uv, then run:
python3 run.py langchain "Draft the release notes" "Review the migration guide" "This exceeds the write grant"The launcher builds the host, starts the API, invokes your tool and stops its child processes. It preserves your notes and session evidence. Each client has a dependency lockfile; the first build downloads the public Chio source revision recorded in SOURCE.json.
[
{
"tool": "createNote",
"arguments": {
"body": {
"text": "Draft the release notes"
}
},
"outcome": "completed",
"receipt_id": "697c831368b021902f39b953c176e5871a3d31eed7c2c56ba2acdbb4fa412713"
},
{
"tool": "createNote",
"arguments": {
"body": {
"text": "Review the migration"
}
},
"outcome": "completed",
"receipt_id": "54061ae0a9e815eb247d6e67310049853f004fd1321fe89cd96aceac4dba79a2"
},
{
"tool": "createNote",
"arguments": {
"body": {
"text": "This exceeds the grant"
}
},
"outcome": "denied",
"receipt_id": "5aa9dcf432d97a8baa8da094f72cbd291c00fd227ad57dcb10cca23d25ed5096"
},
{
"tool": "listNotes",
"arguments": {},
"outcome": "completed",
"receipt_id": "becd9860a9352f1608d79b222bb9295e2cbf88e89f2ddc478c9f88e875020513"
}
]The actual framework run saved two notes, refused the third create and listed the retained data. All four request/receipt associations were independently verified against the host key selected before execution. Download the complete run and its signed receipts.
See the complete framework integration
StructuredTool infers its text argument from session.save. The function sends createNote to the host; it does not write to the API directly. This is the connection inside the runnable script:
with NotesSession() as session:
save_note = StructuredTool.from_function(
func=session.save, name="save_note",
description="Save a note through the governed notes API.",
)
result = save_note.invoke({"text": "Draft the release notes"})Read langchain_notes.py · complete 23 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["langchain-core>=1.2.28,<2"]
# ///
"""Invoke a real LangChain tool against your Chio-governed notes application."""
from langchain_core.tools import StructuredTool
from inputs import note_arguments, save_notes, show_saved
from notes_session import NotesSession
def main():
texts = note_arguments()
with NotesSession() as session:
tool = StructuredTool.from_function(
func=session.save, name="save_note",
description="Save a note under this session's Chio write allowance.",
)
save_notes(lambda text: tool.invoke({"text": text}), texts)
show_saved(session)
if __name__ == "__main__":
main()Keep the session outside the tool function when adding this tool to an existing agent. All calls in the run share one allowance. The host issues a fresh grant on an explicit new program start; a deployed operator must control that issuance.
Find this run’s records
The printed Session evidence directory is inside notes-api/sessions. It contains receipts.db, the signed capability and manifest, the host public key, and mcp.ndjson. Replace SESSION with your printed directory name:
uv run --locked verify_session.py notes-api/sessions/SESSION --trusted-key notes-api/trusted-kernels/SESSION.txtThe verifier checks signatures, content identifiers and the operator-selected host key. It matches each receipt and request ID to the exact tool name and arguments in the captured MCP exchange. The key is pinned outside the session evidence before the first tool call.
Keep authority behind the model loop
Source
The client maps provider messages and sends requests. The host owns the signed capability, evaluates admission and dispatches the API operation. Tool results and refusals return to the model under the original provider call ID. The local tutorial’s processes still share a machine; isolate the authority and upstream API from untrusted agent code when deploying this boundary.
Let Anthropic choose the calls
Run the complete Messages client
Set ANTHROPIC_API_KEY in your environment and ANTHROPIC_MODEL to a tool-capable model available to your account, then run from agent-notes:
python3 run.py anthropic --model "$ANTHROPIC_MODEL" \
"Save a note reminding me to review the release checklist, then list my notes."This command calls Anthropic. The model chooses whether and how to use the tools, so its wording and call sequence vary. The host’s two-write allowance remains the same.
Supply the discovered tool schemas
The client reads the session’s tools/list result and maps each tool’s name, description and inputSchema to Anthropic’s name, description and input_schema. The host filters discovery to this session’s grants; deleteNote is not advertised.
Return tool results and refusals
Each tool_use becomes session.call. Its result goes back as tool_result with the original tool_use_id and an is_error flag when the host refuses it. The client keeps each provider response ID, model and usage alongside the session. It stops after eight model turns:
Read anthropic_notes.py · complete 59 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["anthropic>=0.84,<1"]
# ///
"""Connect Anthropic Messages to your Chio-governed notes application."""
import argparse
import json
import os
from anthropic import Anthropic
from notes_session import NotesSession
def run(client, session, model, prompt):
tools = [{"name": tool["name"],
"description": tool.get("description", ""),
"input_schema": tool["inputSchema"]} for tool in session.tools()]
messages = [{"role": "user", "content": prompt}]
for _ in range(8):
response = client.messages.create(
model=model, messages=messages, tools=tools, max_tokens=512,
)
print(json.dumps({"provider_response_id": response.id, "model": response.model, "usage": response.usage.model_dump() if response.usage else None}))
calls = [block for block in response.content if block.type == "tool_use"]
if not calls:
text = "\n".join(block.text for block in response.content if block.type == "text")
print(text or "The model returned no text or tool call.")
return
if len(calls) > 16:
raise RuntimeError("The model returned more than 16 tool calls in one turn")
messages.append({"role": "assistant", "content": [
block.model_dump(exclude_none=True) for block in response.content]})
results = []
for call in calls:
if isinstance(call.input, dict):
result = session.call(call.name, call.input)
else:
result = {"isError": True, "content": [{"type": "text",
"text": "Tool arguments must be a JSON object"}]}
print(json.dumps({"tool": call.name, "result": result}))
results.append({"type": "tool_result", "tool_use_id": call.id,
"content": session.text(result),
"is_error": bool(result.get("isError"))})
messages.append({"role": "user", "content": results})
raise RuntimeError("Stopped after eight model turns; session evidence is retained")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", required=True, help="A tool-capable model available to your account")
parser.add_argument("prompt", help="Tell the model what notes to save or read")
args = parser.parse_args()
if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")):
parser.error("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in your environment")
with Anthropic(timeout=45, max_retries=0) as client, NotesSession() as session:
run(client, session, args.model, args.prompt)
if __name__ == "__main__":
main()Use OpenAI Chat Completions
Run the complete Chat Completions client
Set OPENAI_API_KEY and a tool-capable OPENAI_MODEL available to your account, then run from agent-notes. The API and host are the same ones used above:
python3 run.py openai --model "$OPENAI_MODEL" \
"Save a note reminding me to review the release checklist, then list my notes."The OpenAI SDK supports OPENAI_BASE_URL for a compatible endpoint. Confirm that endpoint accepts Chat Completions tools and max_completion_tokens; compatibility with arbitrary providers is not established by this example.
Map discovery to function tools
The discovered schema becomes function.parameters. Returned function.arguments is JSON text; the client parses it and requires an object before sending the request. A malformed argument result goes back to the model without an MCP dispatch.
Preserve the call identity through the result
The client appends a role: tool message with the original tool_call_id for every function result. It returns the complete MCP result, including isError for a refusal. The complete client retains the provider responses and their call identifiers:
Read openai_notes.py · complete 62 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["openai>=2,<3"]
# ///
"""Connect OpenAI Chat Completions to your Chio-governed notes application."""
import argparse
import json
import os
from openai import OpenAI
from notes_session import NotesSession
def run(client, session, model, prompt):
tools = [{"type": "function", "function": {
"name": tool["name"], "description": tool.get("description", ""),
"parameters": tool["inputSchema"],
}} for tool in session.tools()]
messages = [{"role": "user", "content": prompt}]
for _ in range(8):
response = client.chat.completions.create(
model=model, messages=messages, tools=tools,
max_completion_tokens=512,
)
print(json.dumps({"provider_response_id": response.id, "model": response.model, "usage": response.usage.model_dump() if response.usage else None}))
message = response.choices[0].message
calls = message.tool_calls or []
if not calls:
print(message.content or "The model returned no text or tool call.")
return
if len(calls) > 16:
raise RuntimeError("The model returned more than 16 tool calls in one turn")
messages.append(message.model_dump(exclude_none=True))
for call in calls:
if call.type != "function":
raise RuntimeError("This example accepts function tool calls only")
try:
arguments = json.loads(call.function.arguments)
if not isinstance(arguments, dict):
raise ValueError("Tool arguments must be a JSON object")
except (json.JSONDecodeError, ValueError) as error:
result = {"isError": True, "content": [{"type": "text", "text": str(error)}]}
else:
result = session.call(call.function.name, arguments)
print(json.dumps({"tool": call.function.name, "result": result}))
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result)})
raise RuntimeError("Stopped after eight model turns; session evidence is retained")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", required=True, help="A tool-capable model available to your account")
parser.add_argument("prompt", help="Tell the model what notes to save or read")
args = parser.parse_args()
if not os.environ.get("OPENAI_API_KEY"):
parser.error("Set OPENAI_API_KEY in your environment")
with OpenAI(timeout=45, max_retries=0) as client, NotesSession() as session:
run(client, session, args.model, args.prompt)
if __name__ == "__main__":
main()Both provider loops disable automatic provider retries and stop after eight model turns. A transport failure ends the run; the client does not invent a tool result or retry a potentially completed write.
Inspect the complete signed artifact
This complete quota-denial receipt comes from a separate captured LangChain session. It demonstrates the same write-limit behavior; its capability, arguments and receipt ID belong to that session. Its signature, content identifier, parameter hash and host-selected signer were verified. Download the full signed JSON.
Read Captured LangChain kernel denial · complete 45 lines · complete file
{
"id": "0092d85416b8bce76a842e988f79566ab266dd900ac119d41a134ff8649d5a5e",
"timestamp": 1788950127,
"capability_id": "cap-01a085bc-9f6f-7610-b34f-68e958ef2a9b",
"tool_server": "notes",
"tool_name": "createNote",
"action": {
"parameters": {
"body": {
"text": "This exceeds the write grant"
}
},
"parameter_hash": "85ba62e99b5f65a6b6d514f59ffc86154151ad54a86f72297968bfbd81a89b56"
},
"decision": {
"verdict": "deny",
"reason": "invocation budget exhausted for capability cap-01a085bc-9f6f-7610-b34f-68e958ef2a9b",
"guard": "kernel"
},
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"tool_origin": "caller_executed",
"redaction_mode": "none",
"content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"policy_hash": "d2ed7d01074c952ab46491c89a53e51d03d9e089fa670861788ace0c28e42fcb",
"metadata": {
"attribution": {
"delegation_depth": 0,
"issuer_key": "ba6bdf961aec7c011b32cdfd2b4a72beeb310a1677dc38f25f3849c8be4cf341",
"subject_key": "e9256adf30c15014b2474b0f22288478ac1d311cda212d0d2328be5af1c4f6a1"
},
"budget_authority": {
"authority_profile": "authoritative_hold_event",
"guarantee_level": "single_node_atomic",
"metering_profile": "max_cost_preauthorize_then_reconcile_actual"
},
"chio_receipt_signing_nonce": "rcpt-01a085bc-a213-7572-8cc7-f33dc6c878fc",
"receipt_context": {
"request_id": "mcp-edge-req-f5c381fb5ee94535995a236cac6600be"
}
},
"trust_level": "mediated",
"kernel_key": "ba6bdf961aec7c011b32cdfd2b4a72beeb310a1677dc38f25f3849c8be4cf341",
"signature": "03a24bc78b1cd48b1830130d64e1fb3d11a0669d734374336518ff87532498838feab60ebe94cb9c8585a0f38266da83de129ebfd6343a71ce039776eaf77503"
}The receipt records the host’s decision for createNote, its arguments and its capability. This host does not attach the external model name, provider call ID or model provenance to that signed kernel record. Keep provider logs separately when those details matter.
Compare decisions without expecting identical receipts
The same operation and authority can produce the same kind of decision across clients. The full signed bytes differ as request IDs, session capabilities, keys, timestamps or arguments change. Compare the relevant operation and verdict, and verify each receipt independently. Do not compare unrelated latest records and call them equivalent.
Cross-Provider Policy runs both model loops with write limits of two and one. Its interactive viewer shows the actual calls, saved notes and refusals, with each complete signed receipt available to inspect and download.
Understand what was exercised
| Path | Observed result |
|---|---|
LangChain and framework tools | Real framework invocations reached the executing kernel and notes API. A shared write grant limited the session; refused calls did not create notes. |
OpenAI and Anthropic model loops | Both made actual model API requests through a funded OpenRouter endpoint. Each was run with a two-write and one-write policy. The four runs retained and independently verified 16 execution receipts. |
Policy changes | The lower allowance reduced completed writes in both providers' runs. Model prose is retained separately from the kernel's signed operation records. |
The recorded models were openai/gpt-4.1-mini and anthropic/claude-haiku-4.5. Use a tool-capable model available to your own account; the chosen wording and tool sequence can vary. Inspect the complete four-run comparison.
Continue with the same notes and authority
Use Query & Audit Receipts with this session’s database, or Verify Receipts Offline with a complete record and trusted host key. The MCP wire capture helps locate a protocol refusal that never reached kernel evaluation.
To permit a third write, edit the writer's max_writes in notes-api/authority.json and start a new session. The host reads the policy at issuance; no Rust edit is required. Each session retains its original policy snapshot.
{
"writer": {
"allow_read": true,
"allow_write": true,
"max_writes": 2
},
"auditor": {
"allow_read": true,
"allow_write": false,
"max_writes": 0
}
}Keep one session alive across the model loop. Your deployed operator should bind issuance and role selection to an authenticated caller; starting a session for each tool call would reset this tutorial's session allowance.
Choose where to connect Chio
| Your application | Use this connection |
|---|---|
Existing LangChain tools | Wrap the governed function as a StructuredTool and keep the session open for your agent run. |
Anthropic Messages loop | Map tool_use to MCP and return tool_result with its call ID and refusal flag. |
OpenAI Chat Completions loop | Map function tool calls to MCP and return role: tool messages with tool_call_id. |
Rust host in one process | Embed the kernel and provider adapter with the complete Rust host. |
Find related framework and source integrations
Agent Framework SDKs continues this same project with CrewAI, AutoGen and LlamaIndex. The LangGraph guide covers graph execution. The executable examples use the new MCP-backed bindings. Legacy ID-only constructors have a different admission boundary; the framework page explains the difference before you migrate an existing wrapper.
Python source package index
| Package | Interface |
|---|---|
LangChain tools | |
Classic AutoGen function registries and group chats | |
CrewAI BaseTool and role scopes | |
LangGraph nodes and approval pauses | |
LlamaIndex function and query-engine tools | |
Temporal activities and workflow records | |
Prefect tasks and flows | |
Dagster assets and operations | |
Airflow operators and TaskFlow | |
Ray tasks and actor methods | |
Hermes plugins | |
Coding-agent connections | |
Terraform and Pulumi plan/apply workflows | |
LangSmith and LangFuse span bridges | |
Stream and event-bus middleware |
These links identify source packages. Their admission, observation and receipt behavior differs; the package index does not establish that every wrapper authorizes execution or that every package is published to a registry.
Original hosted SDK clients and shared transport source
The source also retains LangChain, Anthropic JavaScript and OpenAI-compatible JavaScript clients for an existing hosted deployment. Those advanced source clients expect an MCP edge at 8931, a trust service at 8940 and configured authorization. Their no-provider modes still call those services. The downloadable project above supplies its own host and does not require that topology.
Read notes_session.py · complete shared client 159 lines · complete file
"""One application-owned MCP session, shared by all tools in an agent run."""
import json
import os
from pathlib import Path
import queue
import re
import subprocess
import threading
import uuid
class ToolRefused(RuntimeError):
"""The MCP host returned isError; inspect its receipt for the decision."""
def __init__(self, message, result=None):
association = (result or {}).get("_meta", {}).get("chioReceipt", {})
self.receipt_id = association.get("receiptId")
self.request_id = association.get("requestId")
super().__init__(message)
class NotesSession:
def __init__(self, project=None, role="writer"):
self.project = Path(project or Path(__file__).resolve().parent / "notes-api").resolve()
self.server = Path(os.environ.get("CARGO_TARGET_DIR", self.project / "target")) / "debug/notes-mcp"
self.role = role
self.directory = self.project / "sessions" / str(uuid.uuid4())
self.process = None
self.messages = queue.Queue(maxsize=64)
self.lock = threading.Lock()
self.request_id = 0
self.wire = []
self.closed = False
def __enter__(self):
if not self.server.is_file():
raise RuntimeError("In notes-api, run: cargo build --locked")
self.directory.parent.mkdir(mode=0o700, exist_ok=True)
self.process = subprocess.Popen(
[str(self.server), str(self.directory), self.role], cwd=self.project,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, encoding="utf-8", bufsize=1,
)
self.reader = threading.Thread(target=self._read, daemon=True)
self.reader.start()
try:
self.send("initialize", {
"protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": {"name": "my-agent-notes", "version": "1.0.0"},
})
self.send("notifications/initialized", {}, notification=True)
# The operator launched this host. Pin its key before the first tool
# request, separately from the records later supplied to verification.
public_key = (self.directory / "kernel-public-key.txt").read_text().strip()
if not re.fullmatch(r"[0-9a-f]{64}", public_key):
raise RuntimeError("The owned host did not publish a valid kernel key")
pins = self.project / "trusted-kernels"
pins.mkdir(mode=0o700, exist_ok=True)
with (pins / (self.directory.name + ".txt")).open("x") as output:
output.write(public_key + "\n")
return self
except Exception:
self.close()
raise
def _read(self):
try:
while not self.closed:
line = self.process.stdout.readline(2 * 1024 * 1024 + 1)
if not line:
self.messages.put(None, timeout=1)
return
if len(line.encode("utf-8")) > 2 * 1024 * 1024:
raise RuntimeError("MCP response exceeded the client limit")
self.messages.put(json.loads(line), timeout=1)
except (ValueError, OSError, RuntimeError, queue.Full) as error:
try:
self.messages.put(error, timeout=1)
except queue.Full:
self.process.terminate()
def send(self, method, params, notification=False):
# Serialize requests so every tool consumes the same session allowance.
with self.lock:
if self.closed or self.process is None:
raise RuntimeError("MCP session is closed")
self.request_id += 1
request = {"jsonrpc": "2.0", "method": method, "params": params}
if not notification:
request["id"] = self.request_id
self.wire.append({"direction": "client", "message": request})
try:
self.process.stdin.write(json.dumps(request) + "\n")
self.process.stdin.flush()
if notification:
return None
while True:
response = self.messages.get(timeout=30)
if response is None:
raise RuntimeError("MCP host closed; inspect its stderr")
if isinstance(response, Exception):
raise response
self.wire.append({"direction": "server", "message": response})
if response.get("id") != self.request_id:
continue
if "error" in response:
raise RuntimeError("MCP protocol error: " + json.dumps(response["error"]))
return response["result"]
except (OSError, ValueError, RuntimeError, queue.Empty):
self.close()
raise
def call(self, name, arguments):
return self.send("tools/call", {"name": name, "arguments": arguments})
def tools(self):
return self.send("tools/list", {})["tools"]
@staticmethod
def text(result):
return "\n".join(item["text"] for item in result.get("content", [])
if item.get("type") == "text")
def save(self, text: str) -> str:
"""Save a note through Chio, consuming one granted write."""
result = self.call("createNote", {"body": {"text": text}})
if result.get("isError"):
raise ToolRefused(self.text(result), result)
return self.text(result)
def list(self):
result = self.call("listNotes", {})
if result.get("isError"):
raise ToolRefused(self.text(result), result)
return json.loads(self.text(result))["notes"]
def close(self):
if self.closed:
return
self.closed = True
if self.process:
try:
self.process.stdin.close()
self.process.wait(timeout=10)
except (OSError, subprocess.TimeoutExpired):
self.process.terminate()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
self.reader.join(timeout=2)
self.process.stdout.close()
if self.directory.is_dir():
(self.directory / "mcp.ndjson").write_text(
"".join(json.dumps(item) + "\n" for item in self.wire), encoding="utf-8")
print(f"Session evidence: {os.path.relpath(self.directory)}")
def __exit__(self, *_):
self.close()Grow the application without changing its authority boundary
Change the exposed API tools in Hello MCP, add a framework through Agent Framework SDKs, or use other provider adapters in an embedded Rust host. Keep the original operation, grant and evidence connected as you extend the integration.