LangGraph
Wrap LangGraph nodes so the kernel admits each dispatch, pauses sensitive ones for a person, and signs a receipt.
On this page
A node you wrap with chio_node becomes a tool call that the kernel admits before the node body runs. LangGraph keeps the graph, its state, and its checkpoints. Chio holds a capability for each node and signs a receipt for every dispatch, refusals included.
The adapter sends each dispatch to a Chio sidecar beside the graph process. A second route, the process-backed tool node, runs model-chosen tools inside a durable Chio process and resumes them with their original outcome after a worker restart.
Startr1researcherr2writerr3send_email
Each node dispatch is a tool call the kernel admits
chio_node evaluates the dispatch with tool_server set to langgraph and tool_name set to the node name, under a capability minted for the node's scope. Only an allow runs the wrapped body. The mediated call.
The sidecar treats a node dispatch as a call against a virtual tool server named langgraph. Scope checks, guards, approvals, and receipt signing therefore apply to a node exactly as they apply to any other tool. The kernel refuses a dispatch that claims write access the supervisor never granted, a replayed dispatch carrying a stale capability, and an approval interrupt that would bypass policy.
1. Install the adapter beside your graph
chio-langgraph requires langgraph>=0.6,<2. It pulls in the Chio Python SDK, which provides the sidecar client, and chio-adapter-base, which supplies argument redaction. Run the Chio sidecar next to the process that runs the graph.
Before you start
Python 3.11+ and pip in a macOS or Linux shell. Run the commands from your project directory.
Create a Python environment
Terminal · your projectpython3 -m venv .venv source .venv/bin/activateInstall the SDK and the adapter
Terminal · your project, with .venv activepython -m pip install chio-sdk chio-langgraphCheck the import
Terminal · the same Python environmentpython -c 'from chio_langgraph import ChioGraphConfig, chio_node; print("LangGraph adapter ready")'Expected checkpointLangGraph adapter ready
The adapter imports. A node wrapped with chio_node is evaluated by the kernel, under a capability scoped to that node, before its body runs.
You should see LangGraph adapter ready. The kernel check starts when you wrap a node.
2. Give the graph a ceiling and each node a scope
Build a ChioGraphConfig with the sidecar client, the workflow ceiling, and one scope per node. provision() mints a capability token for the workflow and one for each node; call it before the graph runs.
import asyncio
from chio import ChioClient, ChioScope, Operation, ToolGrant
from chio_langgraph import ChioGraphConfig
SERVER_ID = "demo-srv"
def scope_for(*tools: str) -> ChioScope:
return ChioScope(
grants=[
ToolGrant(server_id=SERVER_ID, tool_name=name, operations=[Operation.INVOKE])
for name in tools
]
)
chio = ChioClient("http://127.0.0.1:9090")
config = ChioGraphConfig(
chio_client=chio,
workflow_scope=scope_for("search", "browse", "write"),
node_scopes={
"researcher": scope_for("search", "browse"),
"writer": scope_for("write"),
},
subject="agent:my-pipeline",
ttl_seconds=3600,
)
# Mint a capability token for the workflow and each node.
asyncio.run(config.provision())Every node scope must be a subset of the workflow ceiling. ChioGraphConfig checks the relation when a scope is registered, and chio_node checks it again at wrap time through enforce_subgraph_ceiling. A scope outside the ceiling raises ChioLangGraphConfigError while the graph is built, before its first run.
The SDK owns that subset check. provision() mints each node token separately against POST /v1/capabilities with the node's own scope, so the kernel sees each token on its own. Keep scope construction in one place.
3. Wrap each node that reaches a tool
chio_node calls evaluate_tool_call with tool_server="langgraph" and tool_name set to the node name, under the node's capability. Only an allow runs the wrapped body.
from typing import TypedDict
from chio_langgraph import chio_node
from langgraph.graph import END, START, StateGraph
class AgentState(TypedDict, total=False):
messages: list[dict]
draft: str
def researcher(state: AgentState) -> dict:
# ...agent work here...
return {"messages": state.get("messages", []) + [{"role": "assistant", "content": "..."}]}
def writer(state: AgentState) -> dict:
return {"draft": "..."}
graph = StateGraph(AgentState)
graph.add_node(
"researcher",
chio_node(researcher, scope=scope_for("search", "browse"), config=config),
)
graph.add_node("writer", chio_node(writer, scope=scope_for("write"), config=config))
graph.add_edge(START, "researcher")
graph.add_edge("researcher", "writer")
graph.add_edge("writer", END)
app = graph.compile()The wrapper keeps sync and async nodes and both LangGraph arities, (state) and (state, config). When the runtime config carries configurable["chio_capability_id"], that id outranks the node token for that dispatch. A supervisor uses this to hand a child subgraph a narrower capability through LangGraph's own config propagation.
4. Pause a node until a person approves it
chio_approval_node evaluates the dispatch first, and a deny raises at once. A pending_approval verdict becomes LangGraph's interrupt(), carrying an ApprovalRequestPayload.
from chio_langgraph import chio_approval_node
async def publish_price(state: AgentState) -> dict:
# ...the side effect here...
return {"published": True}
graph.add_node(
"publish_price",
chio_approval_node(
publish_price,
scope=scope_for("publish_price"),
config=config,
# Optional: a local policy can also require approval even if the
# kernel did not flag pending_approval. Default: always require.
approval_policy=None,
approval_ttl_seconds=3600,
summary="Publish sku-301 at 80 percent off",
),
)The approver's decision comes back through Command(resume=...). An approved resolution runs the body, and any other outcome raises ChioLangGraphError. Keep approver lists, escalation, and timeouts in the kernel's approval policy; the local approval_policy callable only forces an extra pause, for example in a debug build.
The payload carries the parameter_hash of the redacted arguments, never the arguments. When an approver must read the content, render it out of band and reference it from summary or callback_hint.
5. Run the graph and keep each receipt with its checkpoint
A deny, or a node whose token was never provisioned, raises ChioLangGraphError from the wrapper before the body runs.
import logging
from chio_langgraph import ChioLangGraphError
logger = logging.getLogger(__name__)
try:
app.invoke({"messages": []})
except ChioLangGraphError as exc:
# exc.node_name, exc.guard, exc.reason, exc.receipt_id
logger.warning(
"chio denied %s via %s: %s (receipt=%s)",
exc.node_name,
exc.guard,
exc.reason,
exc.receipt_id,
)
raiseYou should see the node name, the guard that refused, the reason, and the receipt id in the log line; the error also names the tool server. On an allow, the sidecar returns the ChioReceipt. Write ChioReceipt.id into your checkpoint metadata at the end of the node body, and a thread id resolves to its receipts.
A process-backed tool node resumes the original tool call
ChioProcessToolNode runs the tools a model chose as native operations of a Chio process. Each call is bound to the graph thread, the assistant-message ID, and the model-generated tool-call ID, so a restarted worker resolves the same operation and receives its original outcome.
from chio_process import ProcessClient
from chio_langgraph import ChioProcessToolNode, ProcessTool
client = ProcessClient(socket_path, credential)
tool_node = ChioProcessToolNode(
client,
[
ProcessTool(
"publish_report",
"reports",
"publish",
"Publish a report.",
{
"type": "object",
"properties": {"report": {"type": "string"}},
"required": ["report"],
},
),
],
namespace="research-workflow-v1",
)
graph.add_node("tools", tool_node)Provision the native host, the process capability, and the tool server first. The trusted host passes a private socket path and credential to the graph worker; keep both out of graph state and tracing metadata. Persist the complete assistant tool-call message in LangGraph's checkpoint before this node runs, and restart with the same thread and message IDs.
A denial, an incomplete result, or a transport error stops the graph. A fresh model decision would be a new operation, so recovery resumes the saved message instead. The node mediates tools; model calls and direct Python effects stay in the graph process.
Read more in Calls and recovery.
The example graph publishes once across a worker exit
Download langgraph.zip and extract it. Use Linux, Chio, Python 3.11+, and uv, and run commands from langgraph/. The manifest declares the graph, checkpoint, and Chio dependencies, and the default planner needs no provider account.
{
"schema": "chio.process.run.v2",
"max_parallel": 1,
"workers": [
{
"process": "reporter",
"command": [
".venv/bin/python",
"graph-worker.py"
],
"cwd": ".",
"input": {
"threadId": "report-42",
"prompt": "Publish a short report",
"receipts": ".chio/receipts.ndjson",
"graphDb": ".chio/graph.db"
},
"max_attempts": 2,
"timeout_seconds": 120
}
]
}uv sync
chio process init --config host.json --state .chio --local-tools
chio process run --state .chio --plan process.jsonThe normal plan completes in one attempt. The recovery plan's first attempt exits after the tool runs and before LangGraph receives the tool message. The replacement resumes the saved assistant message and the original publication.
chio process init --config host-recovery.json --state .chio-recovery --local-tools
chio process run --state .chio-recovery --plan process-recovery.json
chio receipt verify --input .chio-recovery/receipts.ndjson \
--trusted-kernel-pubkey .chio-recovery/kernel.pubYou should see two reporter attempts, one row in .chio-recovery/reports.db, and the original receipt verified against the kernel key. ToolMessage.artifact["chio"] keeps the native response and receipt outside the model-visible tool content.
graph-worker.py separates planning, tool execution, checkpointing, and continuation. durability="sync" commits the assistant message before the tool node runs.
On restart, the same SQLite graph database and thread_id select the saved continuation, and invoke(None, config, durability="sync") resumes it; sending the first user request again starts a new input. The native operation key joins namespace, thread identity, assistant-message ID, and tool-call ID, so keep all four.
Complete graph worker: planning, tool execution, and resumption
"""A complete durable graph with deterministic or configured-provider planning."""
import json
import os
import sqlite3
import sys
from pathlib import Path
from chio_process import ProcessClient
from chio_langgraph import ChioProcessToolNode, ProcessTool, ChioProcessToolError
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import START, END, MessagesState, StateGraph
def build_graph(client, settings, on_receipts_saved):
tools = ChioProcessToolNode(
client,
[
ProcessTool(
"publish_report",
"reports",
"publish",
"Publish one bounded report.",
{
"type": "object",
"properties": {"report": {"type": "string"}},
"required": ["report"],
"additionalProperties": False,
},
)
],
namespace="report-graph-v1",
)
model = None
if settings.get("provider"):
from langchain_openai import ChatOpenAI
# OPENAI_API_KEY is operator environment, never graph state.
model = ChatOpenAI(
model=settings["model"], base_url=settings["endpoint"], max_retries=0
).bind_tools(tools.model_schemas())
def plan(state):
if model is not None:
return {"messages": [model.invoke(state["messages"])]}
if any(isinstance(m, ToolMessage) for m in state["messages"]):
return {"messages": [AIMessage(content="Report published.", id="finished")]}
return {
"messages": [
AIMessage(
content="",
id="report-plan",
tool_calls=[
{
"name": "publish_report",
"id": "publish-report",
"args": {"report": "Three inputs total ten."},
}
],
)
]
}
def execute(state, config):
result = tools.invoke(state, config)
for message in result["messages"]:
with Path(settings["receipts"]).open("a", encoding="utf-8") as output:
output.write(message.artifact["chio"]["receipt_json"] + "\n")
output.flush()
os.fsync(output.fileno())
on_receipts_saved()
return result
graph = StateGraph(MessagesState)
graph.add_node("model", plan)
graph.add_node("tools", execute)
graph.add_edge(START, "model")
graph.add_conditional_edges(
"model", lambda state: "tools" if state["messages"][-1].tool_calls else END
)
graph.add_edge("tools", "model")
return graph
def run_worker(bootstrap, on_receipts_saved=lambda: None):
if bootstrap["schema"] != "chio.process.worker-bootstrap.v1":
raise RuntimeError("Unsupported bootstrap")
connection, settings = bootstrap["connection"], bootstrap["input"]
client = ProcessClient(connection["socket_path"], connection["credential"])
graph = build_graph(client, settings, on_receipts_saved)
config = {"configurable": {"thread_id": settings["threadId"]}}
with sqlite3.connect(settings["graphDb"], check_same_thread=False) as database:
app = graph.compile(checkpointer=SqliteSaver(database))
retained = app.get_state(config)
initial = (
None
if retained.values
else {"messages": [HumanMessage(content=settings["prompt"], id="request")]}
)
try:
result = app.invoke(initial, config, durability="sync")
except ChioProcessToolError as error:
if error.receipt_json:
with Path(settings["receipts"]).open("a") as output:
output.write(error.receipt_json + "\n")
raise
print(json.dumps({"complete": True, "text": result["messages"][-1].content}))
if __name__ == "__main__":
run_worker(json.load(sys.stdin))A provider node that fails before its own response checkpoint leaves a model recovery question for the application, because the tool node journals tools and leaves inference to the graph. Model-call recovery journals the provider response for an AI SDK worker.
Two routes put the kernel inside a graph
Choose the node adapter to authorize existing Python nodes and pause them for approval. Choose the process tool node when a model-chosen tool must resume with its original outcome.
| Route | Kernel admits | Effects retained by | Approval |
|---|---|---|---|
chio_node | Each node dispatch | Your application | interrupt() on pending_approval |
ChioProcessToolNode | Each model tool call | The Chio process | An explicit continuation |
In LangGraph alone, graph structure defines control flow and human review is a UX step. Either route adds capability tokens that scope each node or tool, approval as a kernel verdict, subgraph scopes checked against the parent ceiling, and a signed ChioReceipt for every dispatch. For LangChain agents without a graph, chio-langchain wraps Chio tools as BaseTool instances.
Adapter reference
Try chio_node, redaction_policy, subgraph_config, ChioToolkit, or receipt_json.
Public API
chio-langgraph exports the sidecar adapter below; the process route adds ChioProcessToolNode and ProcessTool.
chio_node. Wraps a node callable so each dispatch is evaluated through the sidecar before the body runs. A deny raisesChioLangGraphError.chio_approval_node. Wraps a node that must await human approval: posts a request, pauses the graph withinterrupt(), and resumes when the caller supplies a decision throughCommand(resume=...).ChioGraphConfig. Graph-level wiring: theChioClient, the workflow scope ceiling, per-node scopes, and, for nested subgraphs, the parent ceiling.enforce_subgraph_ceiling. Validates that a node scope is a subset of the current graph ceiling. Called at wrap time; safe to call eagerly from your code.ApprovalRequestPayload,ApprovalResolution. Wire shapes for the approval flow: the request handed tointerrupt()and the reviewer's answer.ApprovalDispatcher. Type alias for an optional async hook that receives theApprovalRequestPayloadbefore the pause, to post it to an approval service. Unset, the payload surfaces throughinterrupt()only.ApprovalPolicy,ApprovalPolicyDecision. Type aliases for the local pause rule:(state, config)returning a bool or anApprovalRequestPayloadthat overrides default payload fields.ChioLangGraphError,ChioLangGraphConfigError. The config error surfaces at wrap time; the runtime error on a deny.
chio_node signature
def chio_node(
fn,
*,
scope: ChioScope,
config: ChioGraphConfig,
name: str | None = None, # defaults to fn.__name__
tool_server: str = "langgraph", # sidecar tool_server identifier
redaction_policy: RedactionPolicy | None = None, # RedactionPolicy.chio_default()
): ...Capability resolution runs the runtime configurable override first, then the node token, then the workflow token.
Argument redaction
Before evaluation, the wrapper runs chio_adapter_base.redact.redact_args over the node's state-derived parameters. The default RedactionPolicy.chio_default() stubs protected body fields, such as chio_file_write.content and chio_file_edit.patch, to {"omitted": true, "byte_count": N}. The sidecar and every receipt consumer see the byte count; the node body still receives the original arguments. Pass redaction_policy= on chio_node or chio_approval_node to change what is stubbed; a custom policy replaces the default.
Because protected bytes stay out of the sidecar, the parameter_hash for chio_file_write and chio_file_edit is uniform across calls. For per-call forensics, combine byte_count with the path and the receipt id. Enforce constraints over raw payload bytes in your code before invoking the node.
Approval payload and resume values
The payload delivered to interrupt() is the ApprovalRequestPayload as a dict, with fields approval_id, policy_id, subject_id, capability_id, tool_server, tool_name, action, parameter_hash, summary, expires_at, and created_at, plus optional callback_hint and triggered_by. The hash is computed over the redacted arguments, so changing redaction_policy moves the hash and reveals no content.
The resume value can be an ApprovalResolution, a dict with outcome or approved: bool, one of the strings "approved", "denied", or "rejected", or a bare boolean. The wrapper normalizes each into an ApprovalResolution.
Subgraph ceilings
Build a child config with subgraph_config(...). The child's parent_ceiling is pinned to the outer graph's effective ceiling and is the authoritative bound, so every node the subgraph registers attenuates it. A subgraph node scope outside the ceiling raises ChioLangGraphConfigError during graph construction.
# Outer graph: research + write
outer_config = ChioGraphConfig(
chio_client=chio,
workflow_scope=scope_for("search", "browse", "write"),
node_scopes={"outer_plan": scope_for("search")},
)
asyncio.run(outer_config.provision())
# Inner subgraph: research only. The parent ceiling is carried over.
inner_config = outer_config.subgraph_config(
workflow_scope=scope_for("search", "browse"),
node_scopes={
"search": scope_for("search"),
"analyze": scope_for("browse"),
},
subject="agent:research-subgraph",
)
asyncio.run(inner_config.provision())Supervisor and worker delegation
When a supervisor dispatches to a worker, the capability narrows in place. Configure a node scope on ChioGraphConfig for each worker; provision() mints a token per node scope and enforce_subgraph_ceiling validates the subset relation at wrap time. Override the capability id through the configurable hook when one dispatch needs a different capability.
Scoped toolkit injection
ChioToolkit, from chio-langchain, is constructed with a capability id and discovers tools through get_tools(server_id=...). Construct one toolkit per node scope so each node presents only its own capability. get_tools filters on server_id alone and stamps the toolkit's capability id onto every tool the sidecar reports for that server, so the capability decides what the kernel admits, and the listing shows every tool on the server.
Package dependencies
chio-langgraph supplies the node and approval-node wrappers and ChioGraphConfig, on two Chio packages: the Python SDK for the sidecar client and chio-adapter-base for redaction.
dependencies = [
"chio-sdk-python>=0.1.0",
"chio-adapter-base>=0.2.0,<0.3",
"langgraph>=0.2,<1",
"pydantic>=2.5,<3",
]chio-langchain, which supplies ChioTool and ChioToolkit, is a sibling on the same two packages; neither integration imports the other. A team that only wraps tools inside unchanged LangChain agents can use chio-langchain alone, and installing chio-langgraph leaves it out.
dependencies = [
"chio-sdk-python>=0.1.0",
"chio-adapter-base>=0.2.0,<0.3",
"langchain-core>=0.2,<1",
]Process tool node recovery decisions
ChioProcessToolErrorwithreceipt_json. Retain the original receipt and inspect its reason; keep it out of any successfulToolMessage.- Pending approval. Use an explicit approval continuation. A new tool-call ID would be a different operation.
- Transport error or incomplete native result. Keep the original graph checkpoint and resolve the same operation.
- Another concurrent tool already committed. Retain its evidence; a graph failure rolls nothing back.
- Graph checkpoint database unavailable. Stop. A freshly constructed graph can create different tool identities.
- Configured model returns no tool calls. End the graph normally; the application still validates the answer.