Hello ACP
A minimal ACP (Agent Client Protocol) edge wired to a kernel with one in-process tool server. The example runs as a JSON-RPC line loop on stdio, exposes one Chio tool as one ACP capability, and produces signed Chio receipts on every terminal result. It lives at examples/hello-acp.
Prerequisites
What It Shows
The example exercises every method on the authoritative ACP edge surface:
- session/list_capabilities returns the ACP capability advertisement built from the tool manifest.
- tool/invoke is the authoritative path: capability lookup, guard pipeline, tool dispatch, signed receipt, all in one round trip.
- tool/stream returns a working task immediately with
receiptPending: true; execution is deferred untiltool/resume. - tool/resume runs the deferred invocation, signs the receipt, and returns the completed task with the receipt id attached.
Run It
Start the line-based JSON-RPC edge:
cd examples/hello-acp
./run-edge.sh serveRun the smoke flow:
./smoke.shArtifacts land under .artifacts/<timestamp>/ as four JSON files: the capability list, the tool/invoke response, the initial tool/stream response, and the resolved tool/resume response. The smoke flow prints the receipt ids for the invoke and the resumed stream.
Walkthrough
Kernel and Tool Server
The kernel is configured with a generated keypair and a policy hash; that is enough to issue capabilities and sign receipts. The example registers one in-process tool server, HelloToolServer, with one tool named hello_tool. The streaming path returns two chunks; the non-streaming path returns a JSON echo payload.
impl ToolServerConnection for HelloToolServer {
fn server_id(&self) -> &str { "hello-acp-srv" }
fn tool_names(&self) -> Vec<String> { vec!["hello_tool".to_string()] }
fn invoke(&self, _name: &str, arguments: Value, _bridge: Option<&mut dyn NestedFlowBridge>)
-> Result<Value, KernelError>
{
Ok(json!({"message": "hello from acp", "arguments": arguments}))
}
fn invoke_stream(&self, _name: &str, arguments: Value, _bridge: Option<&mut dyn NestedFlowBridge>)
-> Result<Option<ToolServerStreamResult>, KernelError>
{
let name = arguments.get("name").and_then(Value::as_str).unwrap_or("world");
Ok(Some(ToolServerStreamResult::Complete(ToolCallStream {
chunks: vec![
ToolCallChunk { data: json!({"content": [{"type": "text", "text": format!("hello from acp, {name}")}]}) },
ToolCallChunk { data: json!({"content": [{"type": "text", "text": "resume complete"}]}) },
],
})))
}
}Manifest Hints and Category Inference
The tool manifest declares three input-schema hints: x-chio-streaming, x-chio-partial-output, and x-chio-cancellation. ACP organizes capabilities into four categories ( filesystem, terminal, browser, tool); the edge infers the category from the tool name using keyword matching. The name hello_tool does not match any of the filesystem, terminal, or browser keyword sets, so it falls back to the configurable default category tool.
fn demo_manifest() -> ToolManifest {
ToolManifest {
schema: "chio.manifest.v1".to_string(),
server_id: "hello-acp-srv".to_string(),
name: "Hello ACP Server".to_string(),
description: Some("A tiny receipt-bearing ACP hello surface".to_string()),
version: "0.1.0".to_string(),
tools: vec![ToolDefinition {
name: "hello_tool".to_string(),
description: "Return a greeting payload".to_string(),
input_schema: json!({
"type": "object",
"x-chio-streaming": true,
"x-chio-partial-output": true,
"x-chio-cancellation": true
}),
output_schema: None,
pricing: None,
has_side_effects: false,
latency_hint: None,
}],
server_tools: Vec::new(),
required_permissions: None,
public_key: "hello-acp-manifest".to_string(),
}
}The combination of a generic tool category with the three semantic caveats produces an adapted fidelity rating per the bridges spec: the capability is published, but discovery metadata surfaces the caveats. A capability classified as unsupported ( browser category, generic mutating tool, or explicit x-chio-publish: false) is withheld from session/list_capabilities entirely.
Capability and Execution Context
The example issues a 300 second capability scoped to hello_tool with the Invoke operation. The execution context the edge needs is built from the resulting token plus the agent's public key:
let capability = kernel
.issue_capability(
&agent.public_key(),
ChioScope {
grants: vec![ToolGrant {
server_id: "hello-acp-srv".to_string(),
tool_name: "hello_tool".to_string(),
operations: vec![Operation::Invoke],
constraints: vec![],
max_invocations: None,
max_cost_per_invocation: None,
max_total_cost: None,
dpop_required: None,
}],
..ChioScope::default()
},
300,
)
.expect("issue capability");
let execution = AcpKernelExecutionContext {
capability,
agent_id: agent.public_key().to_hex(),
dpop_proof: None,
governed_intent: None,
approval_token: None,
model_metadata: None,
};How an ACP Session Translates Into the Chio Pipeline
The edge does the same job for every method that touches a tool: resolve the capability binding, validate the capability token, run the guard pipeline, dispatch the invocation, sign a receipt. The ACP-specific glue is shape conversion. Permission decisions (handled by session/request_permission) are fail-closed: unknown capabilities deny by default, and capabilities that require permission deny until kernel capability token validation grants them.
Serving JSON-RPC
The serve loop reads a JSON-RPC line from stdin, dispatches it to edge.handle_jsonrpc, and writes the response to stdout. The same call routes session/list_capabilities, tool/invoke, tool/stream, and tool/resume.
fn serve() -> Result<(), Box<dyn Error>> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let (edge, kernel, execution) = build_demo_state();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() { continue; }
let message: Value = serde_json::from_str(&line)?;
let response = edge.handle_jsonrpc(message, &kernel, &execution);
serde_json::to_writer(&mut stdout, &response)?;
writeln!(&mut stdout)?;
stdout.flush()?;
}
Ok(())
}Smoke Flow
The smoke flow drives the four methods in sequence and asserts on the receipt-bearing metadata. The first capability returned must have id hello_tool. The invoke response must report success: true with a non-empty receiptId. The initial stream response must report a working task with receiptPending: true. The resumed response must report status: completed with a receipt id on the result metadata.
listed = rpc({"jsonrpc": "2.0", "id": 1, "method": "session/list_capabilities", "params": {}})
assert listed["result"]["capabilities"][0]["id"] == "hello_tool"
invoked = rpc({
"jsonrpc": "2.0", "id": 2, "method": "tool/invoke",
"params": {"capabilityId": "hello_tool", "arguments": {"name": "world"}},
})
assert invoked["result"]["success"] is True
assert invoked["result"]["metadata"]["chio"]["authorityPath"] == "cross_protocol_orchestrator"
assert invoked["result"]["metadata"]["chio"]["receiptId"]
streamed = rpc({
"jsonrpc": "2.0", "id": 3, "method": "tool/stream",
"params": {"capabilityId": "hello_tool", "arguments": {"name": "world"}},
})
assert streamed["result"]["task"]["status"] == "working"
assert streamed["result"]["task"]["metadata"]["chio"]["receiptPending"] is True
resumed = rpc({"jsonrpc": "2.0", "id": 4, "method": "tool/resume",
"params": {"taskId": streamed["result"]["task"]["id"]}})
assert resumed["result"]["task"]["status"] == "completed"
assert resumed["result"]["result"]["metadata"]["chio"]["receiptId"]Receipt Fields the Edge Guarantees
Every invocation that reaches the tool server flows through the kernel guard pipeline and produces a signed Chio receipt. The bridges spec pins the following fields per ACP invocation:
tool_name: matches the ACP capability id (herehello_tool).server_id: comes from the manifest that owns the tool (herehello-acp-srv).decision:allowfor completed tasks;denyfor guard rejections.
For deferred invocations, the working task carries receiptPending: true instead of a receipt id. The receipt is minted at tool/resume, when the deferred request is materialized through the kernel.
Permission decisions are fail-closed
session/request_permission denies by default for unknown capabilities and for capabilities that require permission. Explicit grants require kernel capability token validation; the demo capability is non-side-effecting and does not require permission, so the example never hits the deny path. For a richer permission flow, see the Wrap an ACP Server guide.When to Use This Edge
Two ACP-shaped flows exist in Chio. They serve different use cases.
| Crate | Direction | When to reach for it |
|---|---|---|
chio-acp-edge (this example) | Serve Chio tools to ACP clients | You own the tools and want IDEs to discover them through ACP |
chio-acp-proxy | Wrap an existing ACP agent | You have a Claude-style coding agent and want chio in front of its JSON-RPC traffic |
session/list_capabilities Request and Response
ACP discovery. The capability list is built from the registered manifest. Each entry carries the inferred category, the published bridge fidelity, and the manifest-derived schema.
{
"jsonrpc": "2.0",
"id": 1,
"method": "session/list_capabilities",
"params": {}
}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"capabilities": [
{
"id": "hello_tool",
"name": "hello_tool",
"description": "Return a greeting payload",
"category": "tool",
"inputSchema": {
"type": "object",
"x-chio-streaming": true,
"x-chio-partial-output": true,
"x-chio-cancellation": true
},
"bridgeFidelity": {
"kind": "adapted",
"caveats": [
"stream-capable tools execute through deferred 'tool/stream' tasks and surface output when resumed via 'tool/resume' rather than as incremental push updates",
"partial output is preserved only inside the resumed terminal payload, not incremental ACP updates",
"cancellation is available on deferred 'tool/stream' tasks via 'tool/cancel'; blocking 'tool/invoke' remains terminal",
"generic Chio tools are exposed through ACP's tool category rather than a native ACP primitive"
]
}
}
]
}
}tool/invoke Request and Response
The synchronous receipt path. One request, one round trip through the kernel pipeline, one signed receipt on the response.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tool/invoke",
"params": {
"capabilityId": "hello_tool",
"arguments": { "name": "world" }
}
}{
"jsonrpc": "2.0",
"id": 2,
"result": {
"success": true,
"result": {
"message": "hello from acp",
"arguments": { "name": "world" }
},
"metadata": {
"chio": {
"receiptId": "rcpt_01HF...invoke",
"decision": "allow",
"capabilityId": "cap_01HF...",
"authorityPath": "cross_protocol_orchestrator",
"authoritative": true,
"receiptBearing": true
}
}
}
}tool/stream Request and Response
The deferred path. The initial response carries receiptPending: true with a working task; no receipt is minted yet.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tool/stream",
"params": {
"capabilityId": "hello_tool",
"arguments": { "name": "world" }
}
}{
"jsonrpc": "2.0",
"id": 3,
"result": {
"task": {
"id": "acp-task-1",
"status": "working",
"metadata": {
"chio": {
"receiptId": null,
"decision": "pending",
"authorityPath": "cross_protocol_orchestrator",
"authoritative": true,
"receiptPending": true,
"lifecycle": {
"toolInvoke": "blocking_terminal",
"toolStream": "deferred_task_resume",
"toolResume": "supported",
"toolCancel": "supported"
}
}
}
}
}
}tool/resume Request and Response
The deferred materialization. The client passes the same taskId returned from tool/stream; the edge runs the kernel pipeline now, signs the receipt, and returns the completed task with the result and receipt id under result.metadata.chio.
{
"jsonrpc": "2.0",
"id": 4,
"method": "tool/resume",
"params": { "taskId": "acp-task-1" }
}{
"jsonrpc": "2.0",
"id": 4,
"result": {
"task": {
"id": "acp-task-1",
"status": "completed"
},
"result": {
"success": true,
"result": {
"content": [
{ "type": "text", "text": "hello from acp, world" },
{ "type": "text", "text": "resume complete" }
]
},
"metadata": {
"chio": {
"receiptId": "rcpt_01HF...resume",
"decision": "allow",
"capabilityId": "cap_01HF...",
"authorityPath": "cross_protocol_orchestrator",
"receiptBearing": true
}
}
}
}
}The two stream chunks the tool produced are collated into the terminal content array. ACP does not push them incrementally for the same reason A2A does not: Chio does not yet advertise a receipt-bearing incremental streaming lifecycle. Cancellation lives on the deferred task only; the synchronous tool/invoke is terminal.
Category Inference Rule
The ACP edge maps every Chio tool into one of four categories. The rule is keyword-based on the tool name; the manifest does not need an explicit category. From crates/chio-acp-edge/src/lib.rs:
fn infer_acp_category(tool: &ToolDefinition, default: AcpCategory) -> AcpCategory {
let name_lower = tool.name.to_lowercase();
if name_lower.contains("read_file")
|| name_lower.contains("write_file")
|| name_lower.contains("list_dir")
|| name_lower.starts_with("fs_")
{
AcpCategory::Filesystem
} else if name_lower.contains("terminal")
|| name_lower.contains("exec")
|| name_lower.contains("shell")
|| name_lower.contains("command")
{
AcpCategory::Terminal
} else if name_lower.contains("browser")
|| name_lower.contains("navigate")
|| name_lower.contains("screenshot")
{
AcpCategory::Browser
} else {
default // AcpCategory::Tool from AcpEdgeConfig::default
}
}For hello_tool: no filesystem keyword, no terminal keyword, no browser keyword. Falls back to the configured default category, which the example leaves as AcpCategory::Tool from AcpEdgeConfig::default(). Combined with x-chio-streaming, x-chio-partial-output, and x-chio-cancellation on the input schema, this produces an adapted fidelity rating with four caveats.
Smoke Assertions
The smoke flow asserts on every method response in sequence. These are literal lines from smoke.sh:
# session/list_capabilities: the first capability id is hello_tool.
assert listed["result"]["capabilities"][0]["id"] == "hello_tool", listed
# tool/invoke: receipt-bearing terminal result with the cross-protocol orchestrator
# authority path and a non-empty receipt id.
assert invoked["result"]["success"] is True, invoked
assert invoked["result"]["metadata"]["chio"]["authorityPath"] == "cross_protocol_orchestrator", invoked
assert invoked["result"]["metadata"]["chio"]["receiptId"], invoked
# tool/stream: working task carrying receiptPending=true, no receipt id yet.
assert streamed["result"]["task"]["status"] == "working", streamed
assert streamed["result"]["task"]["metadata"]["chio"]["receiptPending"] is True, streamed
# tool/resume: completed task with the resolved receipt id on result.metadata.chio.
assert resumed["result"]["task"]["status"] == "completed", resumed
assert resumed["result"]["result"]["metadata"]["chio"]["receiptId"], resumedInspect After
The smoke flow writes four JSON files under .artifacts/<timestamp>/. Inspect them directly with jq to verify the receipt-bearing contract.
ARTIFACTS=$(ls -1dt examples/hello-acp/.artifacts/* | head -1)
# Capability discovery.
jq '.result.capabilities[0] | {id, category, fidelity: .bridgeFidelity.kind}' \
"$ARTIFACTS/list-capabilities.json"
# {
# "id": "hello_tool",
# "category": "tool",
# "fidelity": "adapted"
# }
# Synchronous invoke produces an immediate receipt.
jq '.result | {success, receiptId: .metadata.chio.receiptId, decision: .metadata.chio.decision}' \
"$ARTIFACTS/tool-invoke.json"
# {
# "success": true,
# "receiptId": "rcpt_01HF...invoke",
# "decision": "allow"
# }
# Stream creates a working task; no receipt id until resume.
jq '.result.task | {status, receiptPending: .metadata.chio.receiptPending}' \
"$ARTIFACTS/tool-stream.json"
# {
# "status": "working",
# "receiptPending": true
# }
# Resume completes the task and surfaces the receipt id.
jq '.result | {taskStatus: .task.status, receiptId: .result.metadata.chio.receiptId}' \
"$ARTIFACTS/tool-resume.json"
# {
# "taskStatus": "completed",
# "receiptId": "rcpt_01HF...resume"
# }
# Edge stderr captures every kernel decision.
grep -E "kernel.*(allow|deny)" "$ARTIFACTS/logs/edge.log"When to Use This Edge
Decision rule
Use Hello ACP when you own a Chio tool surface and want IDE clients (Claude-style coding agents, acp-cli clients) to discover and call it through session/list_capabilities plus tool/invoke. The edge fits the IDE traffic shape: blocking call-and-receipt for editor tooling.
Don't use this if you want agent-to-agent A2A traffic (use Hello A2A for the parallel A2A surface), you want to wrap an existing ACP agent rather than serve a new tool ( Wrap an ACP Server covers the proxy variant), or you want HTTP-shaped governance ( OpenAPI Sidecar).
Next Steps
- Wrap an ACP Server · the proxy variant for an existing ACP agent
- Hello A2A · the A2A edge sibling of this example
- Bridges reference · normative spec for the ACP edge, including category inference and fidelity rules