Chio/Docs
LOGIN · JOIN

BuildAgent SDKs

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:

From agent-notesbash
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.

crewai_notes.py · integrationpython
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
crewai_notes.py · completepython
# /// 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.

With OPENAI_API_KEY and a model available to your accountbash
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.

Modern AutoGen tool bindingpython
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
agentchat_notes.py · complete writer/auditor applicationpython
# /// 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

From agent-notesbash
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.

Classic registry integrationpython
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
autogen_notes.py · completepython
# /// 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

From agent-notesbash
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.

LlamaIndex integrationpython
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
llamaindex_notes.py · completepython
# /// 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

FrameworkNative objectChio binding
CrewAI
BaseTool
ChioBaseTool.from_mcp
Modern AutoGen
FunctionTool and AssistantAgent
mcp_tool
Classic AutoGen
ConversableAgent.function_map
ChioFunctionRegistry.register_mcp
LlamaIndex
FunctionTool
ChioFunctionTool.from_mcp

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.

From agent-notes · use your printed SESSIONbash
uv run --locked verify_session.py notes-api/sessions/SESSION   --trusted-key notes-api/trusted-kernels/SESSION.txt

A 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
verify_session.py · completepython
# /// 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.

authority.json · completejson
{
  "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
notes_session.pypython
"""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
tool_inputs.pypython
"""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: NoteBody
Read inputs.py 34 lines · complete file
inputs.pypython
"""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.