Agent Framework SDKs
Connect framework tools to Chio’s Rust kernel. Give a writer and an auditor separate authority over the same notes application.
On this page
Govern the framework’s tool calls
The framework plans and coordinates. Its tool binding sends requests through one application-owned MCP session. Chio’s Rust kernel checks the signed capability, applies the shared allowance and invokes the notes API. When deploying this pattern, keep the authority files and upstream API accessible only to the trusted host; the binding routes these calls through Chio, while the deployment controls access to other execution paths.
Run a complete application
Download agent-notes and extract it anywhere. The project includes the notes API, Rust host and Python adapters. Install Rust 1.94.1 and uv, then run your chosen framework:
python3 run.py crewai "Draft the release notes" "Review the migration guide" "This exceeds the grant"The launcher builds the host, starts the API, invokes your framework and stops its child processes. Two notes are saved. Chio refuses the third create, then the client reads the actual saved data. Notes and signed evidence remain in the project between runs. Each explicit client start issues a new session grant; individual tool calls share its allowance.
CrewAI: bind the tool to Chio
Use ChioBaseTool.from_mcp with your application-owned session and an explicit input model. The returned native CrewAI tool sends its full arguments to the executing host. Its last_mcp_execution retains the response's receipt and request identities.
tool = ChioBaseTool.from_mcp(
session, tool_name="createNote", name="save_note",
description="Save a note through Chio.", args_schema=CreateNoteInput,
)
result = tool.run(body={"text": "Draft the release notes"})Read crewai_notes.py · complete 27 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["chio-crewai", "crewai>=1.15,<2"]
# [tool.uv.sources]
# chio-adapter-base = { path = "sdk/chio-adapter-base" }
# chio-sdk-python = { path = "sdk/chio-sdk-python" }
# chio-crewai = { path = "sdk/chio-crewai" }
# chio-autogen = { path = "sdk/chio-autogen" }
# chio-llamaindex = { path = "sdk/chio-llamaindex" }
# ///
"""Use Chio's supported CrewAI wrapper with an existing MCP session."""
import os
os.environ.setdefault("OTEL_SDK_DISABLED", "true")
os.environ.setdefault("CREWAI_TELEMETRY_DISABLED", "true")
from chio_crewai import ChioBaseTool
from inputs import note_arguments, save_notes, show_saved
from notes_session import NotesSession
from tool_inputs import CreateNoteInput
def main():
with NotesSession() as session:
tool = ChioBaseTool.from_mcp(session, tool_name="createNote", args_schema=CreateNoteInput,
description="Save a note using this session's Chio write grant.")
save_notes(lambda text: tool.run(body={"text": text}), note_arguments())
show_saved(session)
if __name__ == "__main__": main()AgentChat: a writer and a separate auditor
The modern AutoGen example runs two AssistantAgent instances through an actual model API. The writer has a two-write capability. The auditor gets a separate read-only capability, reads the real store and checks the writer's handoff. The host retains distinct caller identities, policies and receipts for the two responsibilities.
python3 run.py agentchat --model "$OPENAI_MODEL" --test-boundary "Attempt to save alpha, beta and gamma once each, then list the saved notes."The boundary test asks the auditor to attempt one write. A prompt can request the action; it cannot expand the auditor's capability. The refused call leaves the store unchanged. This command makes model requests; OPENAI_BASE_URL supports a compatible funded endpoint.
binding = McpToolBinding(session, "createNote")
tool = mcp_tool(binding, schema=CreateNoteInput,
description="Attempt to save a note; Chio decides admission.")
writer = AssistantAgent("writer", model_client=client, tools=[tool],
max_tool_iterations=6, reflect_on_tool_use=True)Read agentchat_notes.py · complete writer/auditor application 78 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["chio-adapter-base", "autogen-agentchat>=0.7,<0.8", "autogen-ext[openai]>=0.7,<0.8"]
# [tool.uv.sources]
# chio-adapter-base = { path = "sdk/chio-adapter-base" }
# chio-sdk-python = { path = "sdk/chio-sdk-python" }
# ///
"""A writer and an auditor use separate Chio authority in modern AgentChat."""
import argparse
import asyncio
import json
import os
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from chio_adapter_base.agentchat import mcp_tool
from chio_adapter_base.mcp import McpToolBinding
from pydantic import BaseModel, ConfigDict
from notes_session import NotesSession
from tool_inputs import CreateNoteInput
class ListNotesInput(BaseModel):
model_config = ConfigDict(extra="forbid")
def agent(name, session, client):
return AssistantAgent(
name, model_client=client,
tools=[
mcp_tool(McpToolBinding(session, "createNote"), schema=CreateNoteInput,
description="Attempt to save a note. Chio decides whether this session may write."),
mcp_tool(McpToolBinding(session, "listNotes"), schema=ListNotesInput,
description="Read the notes actually saved by the application."),
],
system_message="Use tools for facts about saved notes. Report refusals accurately; never claim a failed write succeeded. Do not retry a refused or uncertain write.",
max_tool_iterations=6, reflect_on_tool_use=True,
)
async def main(args):
configuration = dict(model=args.model, api_key=os.environ["OPENAI_API_KEY"],
timeout=45, max_retries=0, parallel_tool_calls=False,
max_tokens=700)
if os.environ.get("OPENAI_BASE_URL"):
configuration.update(base_url=os.environ["OPENAI_BASE_URL"], model_info={
"vision": False, "function_calling": True, "json_output": False,
"family": "unknown", "structured_output": False,
})
client = OpenAIChatCompletionClient(**configuration)
try:
with NotesSession(role="writer") as writer_session:
writer = agent("writer", writer_session, client)
result = await writer.run(task=args.prompt)
records = [message.model_dump(mode="json") for message in result.messages]
(writer_session.directory / "agentchat.json").write_text(json.dumps(records, indent=2))
print(json.dumps({"agent": "writer", "messages": records}))
handoff = str(result.messages[-1].content)
with NotesSession(role="auditor") as auditor_session:
auditor = agent("auditor", auditor_session, client)
task = "Read the saved notes and audit this writer handoff against the actual data: " + handoff
if args.test_boundary:
task += " First attempt createNote once with text 'Auditor boundary probe'; this is an explicit refusal test. Then listNotes and report whether the attempted write was refused. Do not retry it."
result = await auditor.run(task=task)
records = [message.model_dump(mode="json") for message in result.messages]
(auditor_session.directory / "agentchat.json").write_text(json.dumps(records, indent=2))
print(json.dumps({"agent": "auditor", "messages": records}))
finally:
await client.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", required=True)
parser.add_argument("--test-boundary", action="store_true", help="Ask the auditor to attempt one unauthorized write")
parser.add_argument("prompt", help="The writer's task; the auditor checks its saved result")
args = parser.parse_args()
if not os.environ.get("OPENAI_API_KEY"): parser.error("Set OPENAI_API_KEY in your environment")
asyncio.run(main(args))For a model-free check of modern AutoGen's FunctionTool integration, run python3 run.py agentchat-tools with three quoted notes. This exercises the actual tool API without a planner.
Classic AutoGen: register remote execution
python3 run.py autogen "Draft the release notes" "Review the migration guide" "This exceeds the grant"The classic pyautogen path registers a schema and callable with ConversableAgent. ChioFunctionRegistry.register_mcp sends execution through the existing session. A terminal tool error remains a failed framework result, with its receipt association available from the binding.
binding = McpToolBinding(session, "createNote")
registry.register_mcp(binding, schema=CreateNoteInput,
name="save_note", description="Save a note through Chio.")Read autogen_notes.py · complete 36 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["chio-autogen", "pyautogen>=0.2,<0.3", "numpy<2"]
# [tool.uv.sources]
# chio-adapter-base = { path = "sdk/chio-adapter-base" }
# chio-sdk-python = { path = "sdk/chio-sdk-python" }
# chio-crewai = { path = "sdk/chio-crewai" }
# chio-autogen = { path = "sdk/chio-autogen" }
# chio-llamaindex = { path = "sdk/chio-llamaindex" }
# ///
"""Register remote Chio execution with classic AutoGen's actual tool registry."""
import json
from autogen import ConversableAgent
from chio_adapter_base.mcp import McpToolBinding, McpToolError
from chio_autogen import ChioFunctionRegistry
from inputs import note_arguments, save_notes, show_saved
from notes_session import NotesSession
from tool_inputs import CreateNoteInput
def main():
with NotesSession() as session:
executor = ConversableAgent("notes_executor", llm_config=False, human_input_mode="NEVER", code_execution_config=False)
binding = McpToolBinding(session, "createNote")
registry = ChioFunctionRegistry(agent=executor, chio_client=None, server_id="notes")
registry.register_mcp(binding, schema=CreateNoteInput, description="Save a note with Chio.", name="save_note")
def save(text):
succeeded, result = executor.execute_function({"name": "save_note", "arguments": json.dumps({"body": {"text": text}})})
if not succeeded:
if binding.last_execution and binding.last_execution.is_error:
raise McpToolError(binding.last_execution)
raise RuntimeError(result["content"])
return result["content"]
save_notes(save, note_arguments())
show_saved(session)
if __name__ == "__main__": main()LlamaIndex: keep the native FunctionTool
python3 run.py llamaindex "Draft the release notes" "Review the migration guide" "This exceeds the grant"ChioFunctionTool.from_mcp returns LlamaIndex's FunctionTool with both synchronous and asynchronous execution. Its schema comes from the same input model as the host. The wrapper preserves tool failure; inspect the binding's last_execution for the exact operation and receipt.
binding = McpToolBinding(session, "createNote")
tool = ChioFunctionTool.from_mcp(binding, fn_schema=CreateNoteInput,
name="save_note", description="Save a note through Chio.")Read llamaindex_notes.py · complete 33 lines · complete file
# /// script
# requires-python = ">=3.12,<3.13"
# dependencies = ["chio-llamaindex", "llama-index-core>=0.14,<1"]
# [tool.uv.sources]
# chio-adapter-base = { path = "sdk/chio-adapter-base" }
# chio-sdk-python = { path = "sdk/chio-sdk-python" }
# chio-crewai = { path = "sdk/chio-crewai" }
# chio-autogen = { path = "sdk/chio-autogen" }
# chio-llamaindex = { path = "sdk/chio-llamaindex" }
# ///
"""Use Chio's supported LlamaIndex wrapper for remote tool execution."""
from chio_adapter_base.mcp import McpToolBinding, McpToolError
from chio_llamaindex import ChioFunctionTool
from inputs import note_arguments, save_notes, show_saved
from notes_session import NotesSession
from tool_inputs import CreateNoteInput
def main():
with NotesSession() as session:
binding = McpToolBinding(session, "createNote")
tool = ChioFunctionTool.from_mcp(binding, fn_schema=CreateNoteInput,
description="Save a note under this session's Chio write grant.")
def save(text):
output = tool.call(body={"text": text})
if output.is_error:
if binding.last_execution and binding.last_execution.is_error:
raise McpToolError(binding.last_execution)
raise RuntimeError(output.content)
return output.content
save_notes(save, note_arguments())
show_saved(session)
if __name__ == "__main__": main()Choose the object your agent already uses
| Framework | Native object | Chio binding |
|---|---|---|
CrewAI | BaseTool | Chio |
Modern AutoGen | FunctionTool and AssistantAgent | mcp_tool |
Classic AutoGen | Conversable | Chio |
LlamaIndex | FunctionTool | Chio |
Keep one session open for a responsibility's lifetime. The bindings accept the official asynchronous MCP ClientSession or this project's synchronous NotesSession. They leave transport ownership with your application. Errors never silently start another session or retry a possibly completed effect.
Match the framework result to its signed receipt
Every run prints its session directory. The operator pins the owned host's key before tool requests; the verifier uses that separate pin, checks the signed records and matches tool names, arguments, request IDs and receipt IDs to the complete MCP exchange.
uv run --locked verify_session.py notes-api/sessions/SESSION --trusted-key notes-api/trusted-kernels/SESSION.txtA signed denial explains why admission refused the call. An allowed call can still fail in the tool, and a lost transport reply leaves its outcome uncertain. The bindings preserve those distinctions. A receipt ID alone is an association to retrieve; it is not a verified signature.
Read verify_session.py · complete 64 lines · complete file
# /// script
# requires-python = ">=3.11"
# dependencies = ["chio-sdk"]
# [tool.uv.sources]
# chio-sdk = { path = "sdk/chio-py" }
# ///
"""Verify a session's signed receipts and match them to its MCP calls/results."""
import argparse
import json
from pathlib import Path
import sqlite3
from chio.invariants import verify_receipt_with_trusted_signers, canonicalize_json, sha256_hex_utf8
def verify(directory, trusted_key):
directory = Path(directory)
wire = [json.loads(line) for line in (directory/'mcp.ndjson').read_text().splitlines() if line.strip()]
requests = {row['message']['id']: row['message'] for row in wire
if row['direction'] == 'client' and row['message'].get('method') == 'tools/call'}
connection = sqlite3.connect((directory/'receipts.db').resolve().as_uri()+'?mode=ro', uri=True)
try:
receipts = {row[0]: json.loads(row[1]) for row in connection.execute('SELECT receipt_id, raw_json FROM chio_tool_receipts')}
finally: connection.close()
calls = []; seen = set()
for row in wire:
response = row['message']
if row['direction'] != 'server' or response.get('id') not in requests: continue
request = requests[response['id']]
result = response.get('result', {})
association = result.get('_meta', {}).get('chioReceipt', {})
receipt = receipts[association['receiptId']]
checks = verify_receipt_with_trusted_signers(receipt, [trusted_key])
if not checks['ok']: raise ValueError(f'Invalid or untrusted receipt {receipt["id"]}: {checks}')
if receipt['id'] in seen: raise ValueError('A receipt was reused across distinct MCP calls')
seen.add(receipt['id'])
if receipt['metadata']['receipt_context']['request_id'] != association['requestId']:
raise ValueError('Result and signed receipt refer to different operations')
if receipt['action']['parameters'] != request['params']['arguments'] or receipt['tool_name'] != request['params']['name']:
raise ValueError('Signed receipt does not match the submitted tool and arguments')
allowed = receipt['decision']['verdict'] == 'allow'
failed = result.get('isError', False)
if allowed:
mapped = {key: result[key] for key in ('content', 'structuredContent', 'isError') if key in result}
mapped.setdefault('content', [])
mapped.setdefault('isError', False)
if sha256_hex_utf8(canonicalize_json(mapped)) != receipt['content_hash']:
raise ValueError('The returned tool result differs from its signed output hash')
if not failed and not allowed: raise ValueError('A denied operation was reported as successful')
calls.append({'tool': request['params']['name'], 'arguments': request['params']['arguments'],
'outcome': 'denied' if not allowed else ('execution_failed' if failed else 'completed'),
'receipt_id': receipt['id'], 'request_id': association['requestId'],
'verification': checks, 'result': result, 'receipt': receipt})
if len(calls) != len(requests) or not calls:
raise ValueError('At least one tool call has no verifiable terminal receipt')
return {'verified': True, 'calls': calls, 'policy': json.loads((directory/'authority.json').read_text()),
'role': (directory/'role.txt').read_text(), 'trusted_kernel_key': trusted_key}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('session', type=Path)
parser.add_argument('--trusted-key', type=Path, required=True, help='An independently selected kernel public-key file')
args = parser.parse_args()
print(json.dumps(verify(args.session, args.trusted_key.read_text().strip()), indent=2))Change a role without changing the framework
Edit notes-api/authority.json and start a new session. For example, set the writer's max_writes to three. The host reads this configuration when issuing authority; no Rust edit is required. Existing notes and old policy snapshots remain available.
{
"writer": {
"allow_read": true,
"allow_write": true,
"max_writes": 2
},
"auditor": {
"allow_read": true,
"allow_write": false,
"max_writes": 0
}
}A local operator can launch either role. Your deployed service should select roles from authenticated responsibilities and isolate its authority files and API from untrusted agent code.
Keep legacy ID-only calls explicit
The from_mcp methods are the execution path demonstrated here. Older local-executor constructors use an ID-only sidecar method that refuses authorization: a capability identifier is not a signed grant. Likewise, a sidecar cannot sign a parent's delegation without that subject's signer. The new bindings preserve those refusals. Use an already authorized MCP session for each role, or perform signed delegation in your trusted host.
Complete shared transport and input schemas
Read notes_session.py 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()Read tool_inputs.py 10 lines · complete file
"""The same input schema the MCP host exposes for createNote."""
from pydantic import BaseModel, ConfigDict
class NoteBody(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str
class CreateNoteInput(BaseModel):
model_config = ConfigDict(extra="forbid")
body: NoteBodyRead inputs.py 34 lines · complete file
"""CLI input and result formatting shared by the four framework examples."""
import argparse
import json
from notes_session import ToolRefused
def note_arguments():
parser = argparse.ArgumentParser(description="Save your notes through a Chio-governed framework tool.")
parser.add_argument("notes", nargs="+", help="Quote each note; the host grants two writes per session.")
args = parser.parse_args()
if len(args.notes) > 20:
parser.error("Pass at most 20 notes per run")
return args.notes
def save_notes(save, notes):
for text in notes:
try:
print("SAVED " + save(text))
except (ToolRefused, *mcp_error_types()) as error:
print("TOOL ERROR " + str(error))
print(json.dumps({"receipt_id": getattr(error, "receipt_id", None), "request_id": getattr(error, "request_id", None)}))
def show_saved(session):
print("Stored notes: " + json.dumps(session.list()))
def mcp_error_types():
try:
from chio_adapter_base.mcp import McpToolError
return (McpToolError,)
except ImportError:
return ()Grow the responsibilities
Connect your provider loop, compare actual policy outcomes across models, or build a software factory with separate planning, implementation and review authority.