Chio/Docs
LOGIN · JOIN

BuildProtocols

Hello ACP

Expose one Chio-governed tool through an ACP stdio JSON-RPC edge with signed terminal results.

Prerequisites

Rust toolchain and Cargo. The example builds against the local workspace; no network access is required at runtime. The smoke flow uses Python 3 to drive the JSON-RPC loop. See Installation for workspace setup, and Wrap an ACP Server for the proxy variant that intercepts an existing ACP agent process.

What it shows

The example exercises the ACP edge methods:

  • 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 until tool/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:

terminalbash
cd examples/hello-acp
./run-edge.sh serve

Run the smoke flow:

hello-acp · smoketranscript
$ cd examples/hello-acp
$ ./smoke.sh
hello-acp smoke passed
artifacts: <chio-source>/examples/hello-acp/.artifacts/20260905T114346Z
invoke receipt: 78f990d4aca07ce33ae323c2349a0d4398dea32273eb9d7f397c1b06d4ecf851
stream receipt: 37ce5334393eee9bc3e50b4480a01761297d64e365738dd0687471366d9f2a53
exit 0

Two ids for two governed calls, one per path. Both change on every run, because the example generates a fresh kernel keypair each time it starts.

Output files are written 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. Everything below lives in src/lib.rs; src/main.rs is a twelve-line wrapper that parses the mode argument and calls into it. 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. Both invoke and invoke_stream are async (behind #[async_trait::async_trait]) and fail closed on any tool name other than hello_tool.

examples/hello-acp/src/lib.rs23-71rust
impl ToolServerConnection for HelloToolServer {
    fn server_id(&self) -> &str {
        SERVER_ID
    }

    fn tool_names(&self) -> Vec<String> {
        vec![TOOL_NAME.to_string()]
    }

    async fn invoke(
        &self,
        tool_name: &str,
        arguments: Value,
        _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
    ) -> Result<Value, KernelError> {
        if tool_name != TOOL_NAME {
            return Err(KernelError::ToolNotRegistered(tool_name.to_string()));
        }
        Ok(json!({
            "message": "hello from acp",
            "arguments": arguments,
        }))
    }

    async fn invoke_stream(
        &self,
        tool_name: &str,
        arguments: Value,
        _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
    ) -> Result<Option<ToolServerStreamResult>, KernelError> {
        if tool_name != TOOL_NAME {
            return Err(KernelError::ToolNotRegistered(tool_name.to_string()));
        }
        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.

examples/hello-acp/src/lib.rs101-126rust
pub fn demo_manifest() -> ToolManifest {
    ToolManifest {
        schema: "chio.manifest.v1".to_string(),
        server_id: SERVER_ID.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: TOOL_NAME.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(),
    }
}

Each of the three hints adds a caveat, and the generic tool category adds a fourth. Four caveats means an adapted rating: the capability is published, and discovery carries the caveats with it. A capability classified as unsupported ( browser category, generic mutating tool, or explicit x-chio-publish: false, or a target protocol with no registered executor) 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:

examples/hello-acp/src/lib.rs133-166rust
let capability = kernel
    .issue_capability(
        &agent.public_key(),
        ChioScope {
            grants: vec![ToolGrant {
                server_id: SERVER_ID.to_string(),
                tool_name: TOOL_NAME.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,
    )
    .map_err(|error| -> Box<dyn Error + Send + Sync> {
        format!("issue capability: {error}").into()
    })?;

let execution = AcpKernelExecutionContext {
    capability,
    agent_id: agent.public_key().to_hex(),
    dpop_proof: None,
    execution_nonce: None,
    governed_intent: None,
    approval_token: None,
    approval_tokens: Vec::new(),
    threshold_approval_proposal: None,
    supplemental_authorization: None,
    model_metadata: None,
};

How an ACP Session Translates Into the Chio Pipeline

The edge follows the same path for each 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.

rendering
Authoritative tool/invoke runs the full kernel pipeline; deferred tool/stream + tool/resume splits the work but keeps the receipt at the terminal step.

Serving JSON-RPC

The serve loop reads a JSON-RPC line, dispatches it to edge.handle_jsonrpc, and writes the response back. The same call routes session/list_capabilities, tool/invoke, tool/stream, and tool/resume. Two entry points wrap it: serve_stdio() binds it to stdin and stdout, and serve_reader takes any reader and writer.

examples/hello-acp/src/lib.rs183-207rust
pub fn serve_reader<R, W>(reader: R, mut writer: W) -> HelloAcpResult<()>
where
    R: BufRead,
    W: Write,
{
    let state = build_demo_state()?;

    for line in reader.lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        let message: Value = serde_json::from_str(&line)?;
        let response = state
            .edge
            .handle_jsonrpc(message, &state.kernel, &state.execution);
        if let Some(response) = response.as_value() {
            serde_json::to_writer(&mut writer, response)?;
            writeln!(&mut writer)?;
            writer.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. The literal assertions are in Smoke Assertions below.


Receipt Fields the Edge Guarantees

Each invocation that reaches the tool server flows through the kernel guard pipeline and produces a signed Chio receipt. The edge nests the whole receipt under metadata.chio.receipt beside the receiptId shorthand.

hello-acp · invoke-receipttranscript
$ jq '.result.metadata.chio.receipt | del(.metadata)' tool-invoke.json
{
  "action": {
    "parameter_hash": "c05f3d430e01e24c936243d1e2525b8077c5649863eba0384ca2d860922b24e3",
    "parameters": {
      "name": "world"
    }
  },
  "boundary_class": "prevent",
  "capability_id": "cap-01a07161-c35a-70e3-9b45-07f5fb47e7e0",
  "content_hash": "98f164497bff463c9240fde7b03e123b0a54044d2e99fe898a95636244de4a43",
  "decision": {
    "verdict": "allow"
  },
  "id": "78f990d4aca07ce33ae323c2349a0d4398dea32273eb9d7f397c1b06d4ecf851",
  "kernel_key": "1abd58e32ebbc19287189b52946ec8386189d0021cccfa60be28be479447ba97",
  "policy_hash": "hello-acp-policy",
  "receipt_kind": "mediated_decision",
  "redaction_mode": "none",
  "signature": "df85abc8229742ed6b3b6cfde147f919882639f72621bab13f7300afb3d0cdc32e55302fcfb82455de2cafb4677395569c0dd537a5c504351195723d594d3d09",
  "timestamp": 1788608627,
  "tool_name": "hello_tool",
  "tool_origin": "caller_executed",
  "tool_server": "hello-acp-srv",
  "trust_level": "mediated"
}
exit 0allow

Three fields tie the receipt back to the ACP call: tool_name matches the ACP capability id (hello_tool), tool_server comes from the manifest that owns the tool (hello-acp-srv), and decision is an object, not a string: {"verdict": "allow"} for a completed call, and a verdict of deny with a reason and a guard for a guard rejection. The elided metadata branch carries the two stream chunk hashes and the route-selection evidence.

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.

CrateDirectionWhen to reach for it
chio-acp-edge (this example)Serve Chio tools to ACP clientsYou own the tools and want IDEs to discover them through ACP
chio-acp-proxyWrap an existing ACP agentYou 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 is an AcpCapability: six fields, no more. It carries the inferred category, the published bridge fidelity, and whether the capability needs an explicit permission grant. It does not carry the tool's input schema; a client that needs one asks the manifest.

session/list_capabilities requestjson
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "session/list_capabilities",
  "params": {}
}
hello-acp · capabilitytranscript
$ jq '.result.capabilities[0]' list-capabilities.json
{
  "bridgeFidelity": {
    "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"
    ],
    "kind": "adapted"
  },
  "category": "tool",
  "description": "Return a greeting payload",
  "id": "hello_tool",
  "name": "hello_tool",
  "requiresPermission": true
}
exit 0

The wire shape is the struct, under rename_all = "camelCase":

crates/protocol/chio-acp-edge/src/types.rs6-19rust
pub struct AcpCapability {
    /// Capability identifier (matches the Chio tool name).
    pub id: String,
    /// Human-readable name.
    pub name: String,
    /// Description of the capability.
    pub description: String,
    /// The ACP category this maps to (e.g., "tool", "fs", "terminal").
    pub category: AcpCategory,
    /// Whether the capability requires explicit permission.
    pub requires_permission: bool,
    /// Fidelity assessment for this mapping.
    pub bridge_fidelity: BridgeFidelity,
}

requiresPermission is true here because AcpEdgeConfig::default() sets require_permission: true. The capability is still callable: the kernel capability token the example issues is what satisfies it.


tool/invoke Request and Response

The blocking receipt path. One request, one round trip through the kernel pipeline, one signed receipt on the response. The payload arrives under result.data.stream: even a blocking tool/invoke dispatches through invoke_stream when the tool offers it, so both chunks come back inside one terminal result rather than as a single echo object. The projection below elides the bridge, receipt and routeSelection branches of metadata.chio.

tool/invoke requestjson
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tool/invoke",
  "params": {
    "capabilityId": "hello_tool",
    "arguments": { "name": "world" }
  }
}
hello-acp · tool-invoketranscript
$ jq '.result | {success, data,
$       chio: (.metadata.chio | {decision, receiptId, capabilityId, authorityPath,
$                                terminalState})}' tool-invoke.json
{
  "success": true,
  "data": {
    "stream": [
      {
        "content": [
          {
            "text": "hello from acp, world",
            "type": "text"
          }
        ]
      },
      {
        "content": [
          {
            "text": "resume complete",
            "type": "text"
          }
        ]
      }
    ]
  },
  "chio": {
    "decision": "allow",
    "receiptId": "78f990d4aca07ce33ae323c2349a0d4398dea32273eb9d7f397c1b06d4ecf851",
    "capabilityId": "cap-01a07161-c35a-70e3-9b45-07f5fb47e7e0",
    "authorityPath": "cross_protocol_orchestrator",
    "terminalState": {
      "state": "completed"
    }
  }
}
exit 0

tool/stream Request and Response

The deferred path. The initial response carries receiptPending: true with a working task; no receipt is minted yet.

tool/stream requestjson
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tool/stream",
  "params": {
    "capabilityId": "hello_tool",
    "arguments": { "name": "world" }
  }
}
hello-acp · tool-streamtranscript
$ cat tool-stream.json
{
  "id": 3,
  "jsonrpc": "2.0",
  "result": {
    "task": {
      "id": "acp-task-1",
      "metadata": {
        "chio": {
          "authoritative": true,
          "authorityPath": "cross_protocol_orchestrator",
          "capabilityId": null,
          "claimEligible": true,
          "compatibilityOnly": false,
          "decision": "pending",
          "lifecycle": {
            "toolCancel": "supported",
            "toolInvoke": "blocking_terminal_result",
            "toolResume": "supported",
            "toolStream": "deferred_task_resume"
          },
          "receipt": null,
          "receiptBearing": false,
          "receiptId": null,
          "receiptPending": true,
          "runtimeLifecycle": {
            "blockingEntrypoint": "tool/invoke",
            "cancelEntrypoint": "tool/cancel",
            "claimEligible": true,
            "compatibilityOnly": false,
            "followUpEntrypoint": "tool/resume",
            "partialOutputDelivery": "resumed_terminal_payload",
            "streamDelivery": "resumed_terminal_payload",
            "streamEntrypoint": "tool/stream",
            "surface": "acp_authoritative"
          }
        }
      },
      "status": "working",
      "statusMessage": "Task accepted for authoritative deferred execution."
    }
  }
}
exit 0

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.

tool/resume requestjson
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tool/resume",
  "params": { "taskId": "acp-task-1" }
}
hello-acp · tool-resumetranscript
$ jq '{task: {id: .result.task.id, status: .result.task.status},
$      result: (.result.result | {success, data,
$               chio: (.metadata.chio | {decision, receiptId, capabilityId})})}' tool-resume.json
{
  "task": {
    "id": "acp-task-1",
    "status": "completed"
  },
  "result": {
    "success": true,
    "data": {
      "stream": [
        {
          "content": [
            {
              "text": "hello from acp, world",
              "type": "text"
            }
          ]
        },
        {
          "content": [
            {
              "text": "resume complete",
              "type": "text"
            }
          ]
        }
      ]
    },
    "chio": {
      "decision": "allow",
      "receiptId": "37ce5334393eee9bc3e50b4480a01761297d64e365738dd0687471366d9f2a53",
      "capabilityId": "cap-01a07161-c35a-70e3-9b45-07f5fb47e7e0"
    }
  }
}
exit 0

The two stream chunks the tool produced are collated into result.result.data.stream as two objects, each keeping its own content array. ACP does not push them incrementally for the same reason A2A does not: a receipt covers a completed call, so there is no receipt-bearing incremental lifecycle for the edge to advertise. Cancellation lives on the deferred task only; the blocking tool/invoke is terminal.

The advertised lifecycle is on the discovery response rather than on each result, so a client learns the four entrypoints before it calls any of them:

hello-acp · lifecycletranscript
$ jq '.result.metadata.chio | {lifecycle, invokeMode, permissionPreviewOnly,
$                             receiptBearingInvoke, streamDelivery}' list-capabilities.json
{
  "lifecycle": {
    "toolCancel": "supported",
    "toolInvoke": "blocking_terminal_result",
    "toolResume": "supported",
    "toolStream": "deferred_task_resume"
  },
  "invokeMode": "blocking_or_deferred_task",
  "permissionPreviewOnly": true,
  "receiptBearingInvoke": true,
  "streamDelivery": "resumed_terminal_payload"
}
exit 0

Category Inference Rule

The ACP edge maps each Chio tool into one of four categories. The rule is keyword-based on the tool name; the manifest does not need an explicit category.

crates/protocol/chio-acp-edge/src/bridge.rs65-87rust
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
    }
}

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 each method response in sequence, after the edge exits, so a failure leaves the captured files on disk to read:

examples/hello-acp/smoke.sh96-105python
assert listed["result"]["capabilities"][0]["id"] == "hello_tool", listed
assert invoked["result"]["success"] is True, invoked
assert invoked["result"]["metadata"]["chio"]["authorityPath"] == "cross_protocol_orchestrator", invoked
assert invoked["result"]["metadata"]["chio"]["receiptId"], invoked

assert streamed["result"]["task"]["status"] == "working", streamed
assert streamed["result"]["task"]["metadata"]["chio"]["receiptPending"] is True, streamed

assert resumed["result"]["task"]["status"] == "completed", resumed
assert resumed["result"]["result"]["metadata"]["chio"]["receiptId"], resumed

Inspect After

The smoke flow writes four JSON files under .artifacts/<timestamp>/. Every block on this page above was read back out of them with jq, so the same commands reproduce on your own run.

One thing the run directory does not give you is a decision log. The smoke redirects the edge's stderr to logs/edge.log, and the edge writes nothing to it:

hello-acp · edge-logtranscript
$ ARTIFACTS=$(ls -1dt examples/hello-acp/.artifacts/*/ | head -1)
$ wc -c < "$ARTIFACTS/logs/edge.log"
0
exit 0

The evidence for what the kernel decided is the receipt on each response, not a log line.


When to Use This Edge

Decision rule

Use Hello ACP when you own a Chio tool endpoint 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 endpoint), 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