Chio/Docs
LOGIN · JOIN

BuildAgent SDKs

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:

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

Actual LangChain run · verified request associationsjson
[
  {
    "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:

A native LangChain tool, backed by Chiopython
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
langchain_notes.py · completepython
# /// 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:

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

The 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

A model chooses calls; the application host controls authority
A model chooses calls; the application host controls authorityAn agent loop exchanges messages with a model provider and sends selected tool calls through one MCP session. The operator-owned host keeps the signed grant, evaluates calls in the Chio kernel and records signed decisions. Only allowed calls reach the notes API. Tool results and refusals return to the same agent loop.Model providerOpenAI or AnthropicYour agent loopPrompt + discovered toolsRequestTool callResultOne MCP sessionRequest / resultOPERATOR HOSTSigned grantTwo createsChio kernelAuthorize before dispatchAuthoritySigned decisionsHost receipt databaseRecordAllowedYour notes APISave text in SQLiteA model chooses calls; the application host controls authorityAn agent loop exchanges messages with a model provider and sends selected tool calls through one MCP session. The operator-owned host keeps the signed grant, evaluates calls in the Chio kernel and records signed decisions. Only allowed calls reach the notes API. Tool results and refusals return to the same agent loop.Model providerTool-use APIYour agent loopTool request ↔ resultResultOne MCP sessionRetained for the runOPERATOR HOSTSigned grantTwo createsAuthorityChio kernelAuthorize before dispatchSigned decisionsHost receiptdatabaseAllowedYour notes APISave text in SQLite
An agent loop sends selected tool calls through one governed MCP session. The host retains authority and signed decisions; only allowed dispatches reach the notes API.
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:

Live provider commandbash
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
anthropic_notes.py · completepython
# /// 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:

Live provider commandbash
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
openai_notes.py · completepython
# /// 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
Captured LangChain kernel denial · completejson
{
  "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

PathObserved 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.

authority.json · complete role policyjson
{
  "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 applicationUse 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
PackageInterface
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
notes_session.py · complete shared clientpython
"""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.