Chio/Docs
LOGIN · JOIN

BuildProtocols

Hello A2A

Expose one Chio-governed tool as an A2A skill with signed terminal task 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 if you have not built the workspace yet.

What it shows

This example uses one in-process kernel to demonstrate three behaviors:

  • Discovery through the generated A2A Agent Card. The edge produces the card from the registered manifests, including the skill id, name, and a bridgeFidelity rating per skill.
  • Authoritative message/send. The edge resolves the skill, runs the kernel guard pipeline, invokes the tool, and signs a receipt before returning the terminal task result.
  • Deferred message/stream plus task/get. A streaming request returns a working task immediately; the deferred execution and its receipt complete when the client calls task/get.
  • Receipt-bearing metadata attached to terminal task results, so any A2A client can pick the receipt id off result.metadata.chio.receiptId.

Run it

Print the agent card:

terminalbash
cd examples/hello-a2a
./run-edge.sh agent-card

Start the line-based JSON-RPC edge on stdio:

terminalbash
./run-edge.sh serve

Run the smoke flow:

hello-a2a · smoketranscript
$ cd examples/hello-a2a
$ ./smoke.sh
hello-a2a smoke passed
artifacts: <chio-source>/examples/hello-a2a/.artifacts/20260905T114341Z
send receipt: 31c8189574c8a025f7c78952e1a52a9b61515a8dbf919cf1d904c52b31a0de45
stream receipt: 95bae6361e72230e60819e50fedf6c827a3e97b94b380c38f67ea2ff255651fe
exit 0

Two receipt ids for two governed calls: the blocking message/send and the deferred task the streaming path resolves. Both ids change on every run, because the example generates a fresh kernel keypair each time it starts.

The smoke script writes files under .artifacts/<timestamp>/: the agent card, the message/send response, the message/stream response, and the task/get response. It also prints the receipt ids for both the send and the streamed task.


Walkthrough

Kernel and Tool Server

The kernel is configured with a freshly 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 a single in-process tool server, HelloStreamServer, with one tool named hello_task. The streaming path returns two chunks; the non-streaming path returns a single echo payload. Both invoke and invoke_stream are async (behind #[async_trait::async_trait]) and fail closed on any tool name other than hello_task.

examples/hello-a2a/src/lib.rs23-76rust
impl ToolServerConnection for HelloStreamServer {
    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 a2a", "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 text = arguments
            .get("text")
            .and_then(Value::as_str)
            .unwrap_or("world");
        Ok(Some(ToolServerStreamResult::Complete(ToolCallStream {
            chunks: vec![
                ToolCallChunk {
                    data: json!({
                        "type": "text",
                        "text": format!("hello from a2a, {text}")
                    }),
                },
                ToolCallChunk {
                    data: json!({
                        "content": [{
                            "type": "text",
                            "text": "stream complete"
                        }]
                    }),
                },
            ],
        })))
    }
}

Manifest, Skill Mapping, and Fidelity

The tool manifest declares one tool with two A2A-specific input schema hints: x-chio-streaming: true and x-chio-partial-output: true. The edge reads these when it computes bridgeFidelity for each skill. According to the bridges spec, those two caveats push a tool from lossless to adapted: the skill is publishable, but the agent card has to surface the caveat so an A2A client knows what semantics to expect.

FidelityCriteriaBehavior in this example
losslessNo streaming, partial-output, cancellation, or approval caveatsNot the case here
adaptedSide-effect, streaming, partial-output, or cancellation caveatsThe example tool sets x-chio-streaming and x-chio-partial-output; published with caveats
unsupportedApproval-required or x-chio-publish: falseWithheld from the agent card entirely
examples/hello-a2a/src/lib.rs106-130rust
pub fn demo_manifest() -> ToolManifest {
    ToolManifest {
        schema: "chio.manifest.v1".to_string(),
        server_id: SERVER_ID.to_string(),
        name: "Hello A2A Server".to_string(),
        description: Some("A tiny receipt-bearing A2A hello surface".to_string()),
        version: "0.1.0".to_string(),
        tools: vec![ToolDefinition {
            name: TOOL_NAME.to_string(),
            description: "Return a collated greeting".to_string(),
            input_schema: json!({
                "type": "object",
                "x-chio-streaming": true,
                "x-chio-partial-output": true
            }),
            output_schema: None,
            pricing: None,
            has_side_effects: false,
            latency_hint: None,
        }],
        server_tools: Vec::new(),
        required_permissions: None,
        public_key: "hello-a2a-manifest".to_string(),
    }
}

Capability and Execution Context

Before the edge can serve any request it needs an execution context: a capability scoped to the one tool, and the agent id derived from the agent's public key. The example issues a 300 second capability with a single ToolGrant for hello_task with the Invoke operation.

examples/hello-a2a/src/lib.rs137-170rust
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 = A2aKernelExecutionContext {
    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,
};

Serving JSON-RPC

The serve loop is intentionally tiny: read a JSON-RPC line, hand it to edge.handle_jsonrpc with the kernel and execution context, write the response back. The smoke flow reaches each governed code path through that call. Two entry points wrap it: serve_stdio() binds it to stdin and stdout, and serve_reader takes any reader and writer, which is what the tests drive.

examples/hello-a2a/src/lib.rs198-222rust
pub fn serve_reader<R, W>(reader: R, mut writer: W) -> HelloA2aResult<()>
where
    R: BufRead,
    W: Write,
{
    let mut 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(())
}

Note the response.as_value() guard: a JSON-RPC notification produces no response, and the loop writes nothing rather than emitting a null frame.

Request Flow

rendering
Each terminal task carries a chio.receipt.v1; deferred message/stream flips the receipt into the working task and resolves it via task/get.

The smoke flow asserts this response shape: the message/send response carries authorityPath: cross_protocol_orchestrator and a non-empty receiptId; the initial message/stream response carries receiptPending: true; the follow-up task/get response carries the receipt id for the now-completed task. The literal assertions are in Smoke Assertions below.


Receipts on Terminal Results

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 result.metadata.chio.receipt beside the receiptId shorthand, so an A2A client can verify without a second lookup.

hello-a2a · send-receipttranscript
$ jq '.result.metadata.chio.receipt | del(.metadata)' send-response.json
{
  "action": {
    "parameter_hash": "3a477a27451b71eaf6dc49c80b0e2e4c80f3fc5060884497c4246ea2b44d0790",
    "parameters": {
      "message": "world"
    }
  },
  "boundary_class": "prevent",
  "capability_id": "cap-01a07161-b8f8-72a3-871f-c07bf1afecff",
  "content_hash": "9f60be4661ed367601d088804c409177d3fea01b9f1ff79fcb2a1cdd4e12de42",
  "decision": {
    "verdict": "allow"
  },
  "id": "31c8189574c8a025f7c78952e1a52a9b61515a8dbf919cf1d904c52b31a0de45",
  "kernel_key": "133de73c5809534ce22ac8d1c5b12f06b1e59dd875a0ced4592c612fc57874ca",
  "policy_hash": "hello-a2a-policy",
  "receipt_kind": "mediated_decision",
  "redaction_mode": "none",
  "signature": "df30178a057d1849b44fea8f750ceececee128c377ab78692d34423a08cec9a1ebcb6a9e8103faba21c37707e219202a322dbdda3a4b99141fe514c9906bbc00",
  "timestamp": 1788608624,
  "tool_name": "hello_task",
  "tool_origin": "caller_executed",
  "tool_server": "hello-a2a-srv",
  "trust_level": "mediated"
}
exit 0allow

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

For deferred streaming, the working TaskResponse carries receiptPending: true instead of a receipt id; the receipt is minted when task/get executes the deferred request and produces the terminal result.

Skill ambiguity is handled, not collapsed

If multiple manifests define tools with the same name, the edge does not silently merge them. Unqualified ambiguous names are withheld from the published API; deterministic qualified identifiers are published instead. This example registers one tool, but the rule applies when an edge uses multiple manifests.

Caveats and Fidelity Notes

The authoritative A2A profile advertises capabilities.streaming as true on the agent card. What it does not do is push incremental updates: streaming results in this example are collated and returned as a complete task on task/get, not streamed chunk by chunk. That delivery trade-off is what the adapted fidelity rating is signaling.

Default input and output modes on the agent card are fixed to ["text"]. If your tool needs structured input or output, encode it in text or send it through the data part type, which the edge passes through as structured data.


Agent Card JSON

An A2A client starts with the agent card. It is the discovery document; clients read it to learn the skills, the protocol binding, and per-skill bridgeFidelity. The smoke flow captures it through ./run-edge.sh agent-card and asserts on the first skill id.

hello-a2a · agent-cardtranscript
$ cat agent-card.json
{
  "capabilities": {
    "pushNotifications": false,
    "stateTransitionHistory": false,
    "streaming": true
  },
  "defaultInputModes": [
    "text"
  ],
  "defaultOutputModes": [
    "text"
  ],
  "description": "Chio-governed tools exposed as A2A skills",
  "name": "Chio A2A Edge",
  "skills": [
    {
      "bridgeFidelity": {
        "caveats": [
          "stream-capable tools execute through `message/stream` deferred tasks; output is surfaced on follow-up `task/get` rather than incremental transport updates",
          "stream chunks are collated into the terminal task payload instead of pushed as incremental A2A events",
          "partial output is preserved only in the terminal task payload, not incremental updates"
        ],
        "kind": "adapted"
      },
      "description": "Return a collated greeting",
      "id": "hello_task",
      "inputModes": [
        "text"
      ],
      "name": "hello_task",
      "outputModes": [
        "text"
      ],
      "tags": []
    }
  ],
  "supportedInterfaces": [
    {
      "protocolBinding": "JSONRPC",
      "protocolVersion": "1.0",
      "url": "http://localhost:8080"
    }
  ],
  "version": "0.1.0"
}
exit 0

The card's name, description, version, supportedInterfaces[0].url and protocolBinding are the edge defaults, not values this example sets: it constructs the edge with A2aEdgeConfig::default() (crates/protocol/chio-a2a-edge/src/config.rs). Point them at your own deployment by building the config yourself. The url is advertising metadata; this example still speaks JSON-RPC over stdio.

The two manifest hints (x-chio-streaming: true and x-chio-partial-output: true) are what push the rating to adapted with two caveats per hint. Without them the rating would be lossless and the caveats array would be empty. With x-chio-publish: false or x-chio-approval-required: true on the manifest, the skill is omitted from skills[] entirely (unsupported).


message/send Request and Response

The authoritative path. The smoke flow sends one message/send with a single text part and asserts on the receipt-bearing metadata.

message/send requestjson
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "message/send",
  "params": {
    "message": {
      "role": "user",
      "parts": [{ "type": "text", "text": "world" }]
    }
  }
}
hello-a2a · send-responsetranscript
$ jq '.result | {id, status, message,
$       chio: (.metadata.chio | {decision, receiptId, capabilityId, authorityPath,
$                                a2aSurface, streamProjection, terminalState})}' send-response.json
{
  "id": "a2a-task-1",
  "status": "completed",
  "message": {
    "parts": [
      {
        "data": {
          "text": "hello from a2a, world",
          "type": "text"
        },
        "type": "data"
      },
      {
        "text": "stream complete",
        "type": "text"
      }
    ],
    "role": "agent"
  },
  "chio": {
    "decision": "allow",
    "receiptId": "31c8189574c8a025f7c78952e1a52a9b61515a8dbf919cf1d904c52b31a0de45",
    "capabilityId": "cap-01a07161-b8f8-72a3-871f-c07bf1afecff",
    "authorityPath": "cross_protocol_orchestrator",
    "a2aSurface": "authoritative_blocking_send",
    "streamProjection": "collated_final_message",
    "terminalState": {
      "state": "completed"
    }
  }
}
exit 0

Three smoke assertions land on this response: status is completed, authorityPath is cross_protocol_orchestrator, and receiptId is non-empty. Any deny verdict would land on status: failed with a receipt id still attached.

message/send takes the streaming path too

The reply carries two parts, not the single echo object invoke returns: a data part holding the first chunk and a text part holding the second. The edge dispatches through invoke_stream whenever the tool offers it and collates the chunks into the terminal task, so this example's invoke branch is never the one the smoke exercises. The projection above elides the bridge, receipt and routeSelection branches of metadata.chio; the full file is roughly two hundred lines.

message/stream and task/get

The deferred path. The initial response carries receiptPending: true and decision: pending because no receipt has been minted yet. The follow-up task/get executes the deferred request and resolves the receipt id.

hello-a2a · stream-createdtranscript
$ cat stream-created.json
{
  "id": 2,
  "jsonrpc": "2.0",
  "result": {
    "id": "a2a-task-2",
    "metadata": {
      "chio": {
        "authoritative": true,
        "authorityPath": "cross_protocol_orchestrator",
        "capabilityId": null,
        "claimEligible": true,
        "compatibilityOnly": false,
        "decision": "pending",
        "lifecycle": {
          "messageSend": "blocking_terminal_task",
          "messageStream": "deferred_task_poll",
          "taskCancel": "supported",
          "taskGet": "supported"
        },
        "receipt": null,
        "receiptBearing": false,
        "receiptId": null,
        "receiptPending": true,
        "runtimeLifecycle": {
          "blockingEntrypoint": "message/send",
          "cancelEntrypoint": "task/cancel",
          "claimEligible": true,
          "compatibilityOnly": false,
          "followUpEntrypoint": "task/get",
          "partialOutputDelivery": "collated_terminal_payload",
          "streamDelivery": "collated_terminal_payload",
          "streamEntrypoint": "message/stream",
          "surface": "a2a_authoritative"
        }
      }
    },
    "status": "working",
    "statusMessage": "Task accepted for authoritative deferred execution."
  }
}
exit 0
task/get requestjson
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "task/get",
  "params": { "taskId": "a2a-task-2" }
}
hello-a2a · task-gettranscript
$ jq '.result | {id, status, message,
$       chio: (.metadata.chio | {decision, receiptId, capabilityId})}' task-get-response.json
{
  "id": "a2a-task-2",
  "status": "completed",
  "message": {
    "parts": [
      {
        "data": {
          "text": "hello from a2a, world",
          "type": "text"
        },
        "type": "data"
      },
      {
        "text": "stream complete",
        "type": "text"
      }
    ],
    "role": "agent"
  },
  "chio": {
    "decision": "allow",
    "receiptId": "95bae6361e72230e60819e50fedf6c827a3e97b94b380c38f67ea2ff255651fe",
    "capabilityId": "cap-01a07161-b8f8-72a3-871f-c07bf1afecff"
  }
}
exit 0

Note the contract on the streaming path: the two stream chunks the tool produced are collated into the terminal task payload. Chunk one has no content key, so the converter wraps it as a data part; chunk two does, so it becomes a text part. A2A does not push them incrementally; the receipt is minted once, at the terminal step, when task/get materializes the deferred run. This delivery trade-off is what the adapted fidelity rating signals on the agent card.


Cancelling a deferred task

The edge adds metadata to A2A responses with lifecycle.taskCancel: "supported". That flag comes from the same runtime lifecycle contract that drives the rest of the metadata, and it backs a signed task/cancel entrypoint: a client that started a deferred message/stream task can cancel it by sending task/cancel with the same taskId before it calls task/get. The smoke flow does not exercise cancellation, but the entrypoint is part of the A2A endpoint.


How bridgeFidelity Is Computed

The edge classifies each skill into one of three states based on manifest hints and on whether a target executor is registered for the protocol the tool asks for. The logic lives in evaluate_bridge_fidelity:

crates/protocol/chio-a2a-edge/src/bridge.rs77-134rust
fn evaluate_bridge_fidelity(
    tool: &ToolDefinition,
    target_protocol: DiscoveryProtocol,
) -> BridgeFidelity {
    let registry = authoritative_target_registry();
    let hints = semantic_hints_for_tool(tool);
    let lifecycle = runtime_lifecycle_contract(RuntimeLifecycleSurface::A2aAuthoritative);
    if !hints.publish {
        return BridgeFidelity::Unsupported {
            reason: "publication disabled by x-chio-publish=false".to_string(),
        };
    }
    if !registry.supports_target_protocol(target_protocol) {
        return BridgeFidelity::Unsupported {
            reason: format!(
                "A2A authoritative execution does not yet have a registered `{target_protocol}` target executor"
            ),
        };
    }
    if hints.approval_required {
        return BridgeFidelity::Unsupported {
            reason: "requires interactive approval semantics that the current A2A edge cannot truthfully project".to_string(),
        };
    }
    let mut caveats = Vec::new();
    if tool.has_side_effects {
        caveats.push(
            "A2A publication cannot project protocol-native permission prompts; callers must rely on Chio capability enforcement".to_string(),
        );
    }
    if hints.streams_output {
        caveats.push(format!(
            "stream-capable tools execute through `{}` deferred tasks; output is surfaced on follow-up `{}` rather than incremental transport updates",
            lifecycle.stream_entrypoint, lifecycle.follow_up_entrypoint
        ));
        caveats.push(
            "stream chunks are collated into the terminal task payload instead of pushed as incremental A2A events".to_string(),
        );
    }
    if hints.partial_output {
        caveats.push(
            "partial output is preserved only in the terminal task payload, not incremental updates"
                .to_string(),
        );
    }
    if hints.supports_cancellation {
        caveats.push(format!(
            "cancellation is available only for deferred `{}` tasks; blocking `{}` remains terminal",
            lifecycle.stream_entrypoint, lifecycle.blocking_entrypoint
        ));
    }

    if caveats.is_empty() {
        BridgeFidelity::Lossless
    } else {
        BridgeFidelity::Adapted { caveats }
    }
}

For hello_task: x-chio-streaming: true adds two caveats (deferred task entrypoint, chunk collation), and x-chio-partial-output: true adds one caveat. Result: adapted with three caveats, published in the agent card. The caveat strings are built from the runtime lifecycle contract rather than written out, which is why the card quotes message/stream and task/get in backticks.


Smoke Assertions

The smoke flow makes the contract explicit. Discovery is asserted first, on the card the edge prints before the serve loop starts:

examples/hello-a2a/smoke.sh19-27python
agent_card = json.loads(
    subprocess.check_output(
        [str(example_root / "run-edge.sh"), "agent-card"],
        cwd=example_root,
        text=True,
    )
)
(artifacts / "agent-card.json").write_text(json.dumps(agent_card, indent=2), encoding="utf-8")
assert agent_card["skills"][0]["id"] == "hello_task", agent_card

The three responses are asserted after the edge exits, so a failure leaves the captured files on disk to read:

examples/hello-a2a/smoke.sh100-108python
assert send_response["result"]["status"] == "completed", send_response
assert send_response["result"]["metadata"]["chio"]["authorityPath"] == "cross_protocol_orchestrator", send_response
assert send_response["result"]["metadata"]["chio"]["receiptId"], send_response

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

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

Inspect After

The smoke flow leaves a timestamped directory with four captured JSON files. Inspect them directly to confirm the receipt-bearing response shape.

hello-a2a · inspecttranscript
$ ARTIFACTS=$(ls -1dt examples/hello-a2a/.artifacts/*/ | head -1)
$ jq -r '.skills[0].bridgeFidelity.kind' "$ARTIFACTS/agent-card.json"
$ jq -r '.result.metadata.chio.receiptId' "$ARTIFACTS/send-response.json"
$ jq -r '.result.metadata.chio.receiptPending' "$ARTIFACTS/stream-created.json"
$ jq -r '.result.metadata.chio.receiptId' "$ARTIFACTS/task-get-response.json"
adapted
31c8189574c8a025f7c78952e1a52a9b61515a8dbf919cf1d904c52b31a0de45
true
95bae6361e72230e60819e50fedf6c827a3e97b94b380c38f67ea2ff255651fe
exit 0

The last line is the same stream receipt id the smoke printed on exit, which is the check worth making: the deferred task and the receipt refer to the same governed call. The second line is the send receipt, and it differs, because the two governed calls are two receipts.


When to Use This Edge

Decision rule

Use Hello A2A when you own a Chio tool endpoint and want A2A clients to discover and call it through agent cards plus message/send / task/get. The edge is the right shape for IoA-style agent-to-agent traffic where the caller speaks A2A natively.

Don't use this if you want to expose tools to ACP-shaped IDE clients (use Hello ACP instead), you want a sidecar in front of an existing HTTP service (use OpenAPI Sidecar), or you want LLM-side tool dispatch from a provider SDK (use Agent SDKs).


Next Steps

  • A2A integration · the consumer-side A2A adapter (the reverse direction of this edge)
  • Bridges reference · normative spec for all edges and bridges, including fidelity rules
  • Hello ACP · the parallel example for the Agent Client Protocol