Hello A2A
A tiny A2A edge that takes one Chio tool and projects it as one A2A skill. The example wires chio-a2a-edge to a kernel with a single in-process tool server, runs a JSON-RPC line loop on stdio, and produces signed Chio receipts on every terminal task result. It lives at examples/hello-a2a.
Prerequisites
What It Shows
Three things, all on a single in-process kernel:
- Discovery through the generated A2A Agent Card. The edge produces the card from the registered manifests, including the skill id, name, and a
bridgeFidelityrating 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 callstask/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:
cd examples/hello-a2a
./run-edge.sh agent-cardStart the line-based JSON-RPC edge on stdio:
./run-edge.sh serveRun the smoke flow:
./smoke.shThe smoke script writes artifacts 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. 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.
impl ToolServerConnection for HelloStreamServer {
fn server_id(&self) -> &str { "hello-a2a-srv" }
fn tool_names(&self) -> Vec<String> { vec!["hello_task".to_string()] }
fn invoke(&self, _name: &str, arguments: Value, _bridge: Option<&mut dyn NestedFlowBridge>)
-> Result<Value, KernelError>
{
Ok(json!({"message": "hello from a2a", "arguments": arguments}))
}
fn invoke_stream(&self, _name: &str, arguments: Value, _bridge: Option<&mut dyn NestedFlowBridge>)
-> Result<Option<ToolServerStreamResult>, KernelError>
{
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.
| Fidelity | Criteria | Behavior in this example |
|---|---|---|
lossless | No streaming, partial-output, cancellation, or approval caveats | Not the case here |
adapted | Side-effect, streaming, partial-output, or cancellation caveats | The example tool sets x-chio-streaming and x-chio-partial-output; published with caveats |
unsupported | Approval-required or x-chio-publish: false | Withheld from the agent card entirely |
fn demo_manifest() -> ToolManifest {
ToolManifest {
schema: "chio.manifest.v1".to_string(),
server_id: "hello-a2a-srv".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: "hello_task".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.
let capability = kernel
.issue_capability(
&agent.public_key(),
ChioScope {
grants: vec![ToolGrant {
server_id: "hello-a2a-srv".to_string(),
tool_name: "hello_task".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 = A2aKernelExecutionContext {
capability,
agent_id: agent.public_key().to_hex(),
dpop_proof: None,
governed_intent: None,
approval_token: None,
model_metadata: None,
};Serving JSON-RPC
The serve loop is intentionally tiny: read a JSON-RPC line from stdin, hand it to edge.handle_jsonrpc with the kernel and execution context, write the response back to stdout. Every governed code path the smoke flow exercises is reached through that single call.
fn serve() -> Result<(), Box<dyn Error>> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let (mut 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(())
}Request Flow
The smoke flow asserts exactly that 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.
send_response = rpc({
"jsonrpc": "2.0", "id": 1, "method": "message/send",
"params": {"message": {"role": "user", "parts": [{"type": "text", "text": "world"}]}},
})
assert send_response["result"]["status"] == "completed"
assert send_response["result"]["metadata"]["chio"]["authorityPath"] == "cross_protocol_orchestrator"
assert send_response["result"]["metadata"]["chio"]["receiptId"]
stream_created = rpc({
"jsonrpc": "2.0", "id": 2, "method": "message/stream",
"params": {"message": {"role": "user", "parts": [{"type": "text", "text": "world"}]}},
})
assert stream_created["result"]["status"] == "working"
assert stream_created["result"]["metadata"]["chio"]["receiptPending"] is True
task_resolved = rpc({"jsonrpc": "2.0", "id": 3, "method": "task/get",
"params": {"taskId": stream_created["result"]["id"]}})
assert task_resolved["result"]["status"] == "completed"
assert task_resolved["result"]["metadata"]["chio"]["receiptId"]Receipts on Terminal Results
Every invocation that reaches the tool server flows through the kernel guard pipeline and produces a signed Chio receipt. The receipt fields the bridges spec requires for an A2A invocation are:
tool_name: matches the A2A skill id (herehello_task).server_id: comes from the manifest that owns the tool (herehello-a2a-srv).decision:allowfor completed tasks,denyfor guard rejections.
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 the result is materialized.
Skill ambiguity is handled, not collapsed
Caveats and Fidelity Notes
The current authoritative A2A profile fixes capabilities.streaming on the agent card to false: Chio does not yet advertise a receipt-bearing streaming/task lifecycle. Streaming results in this example are collected and returned as a complete task on task/get, not pushed incrementally. That 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
The first thing any A2A client touches is the agent card. It is the discovery surface; an A2A client reads 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.
{
"name": "chio-a2a-edge",
"description": "Chio-governed tools exposed as A2A skills",
"version": "0.1.0",
"supportedInterfaces": [
{
"url": "stdio://",
"protocolBinding": "json-rpc-stdio",
"protocolVersion": "1.0"
}
],
"capabilities": {
"streaming": true,
"pushNotifications": false,
"stateTransitionHistory": false
},
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"skills": [
{
"id": "hello_task",
"name": "hello_task",
"description": "Return a collated greeting",
"tags": [],
"inputModes": ["text"],
"outputModes": ["text"],
"bridgeFidelity": {
"kind": "adapted",
"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"
]
}
}
]
}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.
{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{ "type": "text", "text": "world" }]
}
}
}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"id": "a2a-task-1",
"status": "completed",
"message": {
"role": "agent",
"parts": [
{
"type": "data",
"data": {
"message": "hello from a2a",
"arguments": { "text": "world" }
}
}
]
},
"metadata": {
"chio": {
"receiptId": "rcpt_01HF...send",
"decision": "allow",
"capabilityId": "cap_01HF...",
"authorityPath": "cross_protocol_orchestrator",
"authoritative": true,
"compatibilityOnly": false,
"claimEligible": true,
"receiptBearing": true,
"lifecycle": {
"messageSend": "blocking_terminal_task",
"messageStream": "deferred_task_poll",
"taskGet": "supported",
"taskCancel": "supported"
}
}
}
}
}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/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.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"id": "a2a-task-2",
"status": "working",
"statusMessage": "Task accepted for authoritative deferred execution.",
"metadata": {
"chio": {
"receiptId": null,
"decision": "pending",
"capabilityId": null,
"authorityPath": "cross_protocol_orchestrator",
"authoritative": true,
"compatibilityOnly": false,
"claimEligible": true,
"receiptBearing": false,
"receiptPending": true,
"lifecycle": {
"messageSend": "blocking_terminal_task",
"messageStream": "deferred_task_poll",
"taskGet": "supported",
"taskCancel": "supported"
}
}
}
}
}{
"jsonrpc": "2.0",
"id": 3,
"method": "task/get",
"params": { "taskId": "a2a-task-2" }
}{
"jsonrpc": "2.0",
"id": 3,
"result": {
"id": "a2a-task-2",
"status": "completed",
"message": {
"role": "agent",
"parts": [
{ "type": "text", "text": "hello from a2a, world" },
{ "type": "text", "text": "stream complete" }
]
},
"metadata": {
"chio": {
"receiptId": "rcpt_01HF...stream",
"decision": "allow",
"capabilityId": "cap_01HF...",
"authorityPath": "cross_protocol_orchestrator",
"authoritative": true,
"receiptBearing": true,
"lifecycle": {
"messageSend": "blocking_terminal_task",
"messageStream": "deferred_task_poll",
"taskGet": "supported",
"taskCancel": "supported"
}
}
}
}
}Note the contract on the streaming path: the two stream chunks the tool produced are collated into the terminal task payload as two text parts. A2A does not push them incrementally because Chio does not yet advertise a receipt-bearing streaming lifecycle. That trade-off is exactly what the adapted fidelity rating signals on the agent card.
How bridgeFidelity Is Computed
The edge classifies each skill into one of three states based on manifest hints. The logic lives in evaluate_bridge_fidelity in crates/chio-a2a-edge/src/lib.rs:
fn evaluate_bridge_fidelity(tool: &ToolDefinition, target_protocol: DiscoveryProtocol) -> BridgeFidelity {
let hints = semantic_hints_for_tool(tool);
if !hints.publish {
return BridgeFidelity::Unsupported {
reason: "publication disabled by x-chio-publish=false".to_string(),
};
}
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(/* side-effect caveat */); }
if hints.streams_output { caveats.push(/* streaming caveat */); }
if hints.partial_output { caveats.push(/* partial output caveat */); }
if hints.supports_cancellation { caveats.push(/* cancellation caveat */); }
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.
Smoke Assertions
The smoke flow makes the contract explicit. Each assertion below is a literal line from smoke.sh:
# Agent card discovery: the first published skill is hello_task.
assert agent_card["skills"][0]["id"] == "hello_task", agent_card
# message/send: completed terminal task with cross_protocol_orchestrator authority
# and a non-empty receipt id.
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
# message/stream: working task carrying receiptPending=true; receipt id is null
# until task/get materializes the deferred run.
assert stream_created["result"]["status"] == "working", stream_created
assert stream_created["result"]["metadata"]["chio"]["receiptPending"] is True, stream_created
# task/get: now-completed task carrying the receipt id for the deferred run.
assert task_resolved["result"]["status"] == "completed", task_resolved
assert task_resolved["result"]["metadata"]["chio"]["receiptId"], task_resolvedInspect After
The smoke flow leaves a timestamped artifact directory with four captured JSON files. Inspect them directly to confirm the receipt-bearing shape end-to-end.
# Find the most recent run.
ARTIFACTS=$(ls -1dt examples/hello-a2a/.artifacts/* | head -1)
# Skill discovery and bridge fidelity.
jq '.skills[0] | {id, bridgeFidelity}' "$ARTIFACTS/agent-card.json"
# {
# "id": "hello_task",
# "bridgeFidelity": { "kind": "adapted", "caveats": [...] }
# }
# Receipt id from the synchronous send.
jq '.result.metadata.chio.receiptId' "$ARTIFACTS/send-response.json"
# "rcpt_01HF...send"
# Streaming created a working task; no receipt id yet.
jq '.result | {status, receiptId: .metadata.chio.receiptId, receiptPending: .metadata.chio.receiptPending}' \
"$ARTIFACTS/stream-created.json"
# {
# "status": "working",
# "receiptId": null,
# "receiptPending": true
# }
# task/get resolves the streamed task and surfaces the receipt id.
jq '.result | {status, receiptId: .metadata.chio.receiptId}' \
"$ARTIFACTS/task-get-response.json"
# {
# "status": "completed",
# "receiptId": "rcpt_01HF...stream"
# }
# Stderr from the JSON-RPC edge: every governed call leaves a kernel log line.
grep -E "kernel.*(allow|deny)" "$ARTIFACTS/logs/edge.log"When to Use This Edge
Decision rule
Use Hello A2A when you own a Chio tool surface 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