BuildConnect
Govern Any Provider's Tool Calls
Send Anthropic and Bedrock tool calls through the Chio kernel and return provider-native results.
Prerequisites
chio replay traffic reads a capture offline.Shared Provider Interface
chio-tool-call-fabric defines the wire shape a native adapter's lift produces (a ToolInvocation), the verdict shape its lower consumes (a VerdictResult), and the streaming state machine that buffers a tool-call block until the verdict resolves. It holds no provider-specific logic and performs no I/O.
Three crates implement the ProviderAdapter trait directly: the OpenAI adapter, chio-anthropic-tools-adapter, and chio-bedrock-converse-adapter. Gemini, Groq, Mistral, Cohere, and Ollama use the same ToolInvocation / ProviderError / VerdictResult vocabulary in their own lift/lower functions without implementing the trait. The ProviderId enum names all eight, and each carries a Principal variant scoped to that provider's native identity boundary.
| ProviderId | Principal variant | Identity scope |
|---|---|---|
open_ai | OpenAiOrg | org_id |
anthropic | AnthropicWorkspace | workspace_id |
bedrock | BedrockIam | caller_arn, account_id, optional assumed_role_session_arn |
gemini | GeminiProject | project_id |
groq | GroqProject | project_id |
mistral | MistralProject | project_id |
cohere | CohereOrg | org_id |
ollama | OllamaHost | host (local daemon, no upstream identity provider) |
The Lift / Lower Contract
A ToolInvocation is what each adapter's lift emits. It names the provider, the tool, the canonical-JSON argument bytes, and a ProvenanceStamp binding the call to an upstream request id and principal.
pub struct ToolInvocation {
pub provider: ProviderId,
pub tool_name: String,
/// Canonical-JSON bytes (RFC 8785). Stored as raw bytes so the kernel can
/// hash without re-serializing.
pub arguments: Vec<u8>,
pub provenance: ProvenanceStamp,
}pub struct ProvenanceStamp {
pub provider: ProviderId,
pub request_id: String,
pub api_version: String,
pub principal: Principal,
pub received_at: SystemTime,
}arguments is a byte vector, not a parsed value, which is why a serialized ToolInvocation shows it as an array of integers. Here is a real one, the Anthropic single-tool fixture the crate pins its wire contract against:
$ jq . crates/protocol/chio-tool-call-fabric/fixtures/lift_lower/anthropic/single_tool_use.json{
"arguments": [
123,
34,
116,
105,
99,
107,
101,
114,
34,
58,
34,
65,
78,
84,
72,
34,
125
],
"provenance": {
"api_version": "anthropic.2023-06-01",
"principal": {
"kind": "anthropic_workspace",
"workspace_id": "wks_chio_demo"
},
"provider": "anthropic",
"received_at": {
"nanos_since_epoch": 0,
"secs_since_epoch": 1745452803
},
"request_id": "msg_01abcdEFGHJK1234567890mn"
},
"provider": "anthropic",
"tool_name": "get_stock_price"
}Those seventeen bytes decode to {"ticker":"ANTH"}. The keys are alphabetical because the file is canonical JSON, not because the struct declares them that way, and api_version here is the fixture's own label rather than the adapter constant. The Bedrock fixtures carry the richer principal:
$ jq '.provenance.principal' \
crates/protocol/chio-tool-call-fabric/fixtures/lift_lower/bedrock/assumed_role.json{
"account_id": "123456789012",
"assumed_role_session_arn": "arn:aws:sts::123456789012:assumed-role/ChioAgentRole/session-1",
"caller_arn": "arn:aws:iam::123456789012:role/ChioAgentRole",
"kind": "bedrock_iam"
}ToolInvocation::validate is the fail-closed check an adapter or a replay consumer runs before trusting a value that crossed a boundary. It enforces the invariants the struct shape alone cannot:
providermust equalprovenance.provider.- The
Principalvariant must match the provider. A Bedrock invocation carrying anAnthropicWorkspaceprincipal is rejected. tool_name,request_id, andapi_versionmust be non-empty, free of surrounding whitespace, and free of control characters.- The same identity rules apply to the principal's own fields:
org_id,workspace_id,caller_arn,account_id,assumed_role_session_arn,project_idandhost. argumentsmust re-canonicalize to itself byte-for-byte. Bytes that are not JSON at all fail earlier withInvalidArgumentJson; bytes that are JSON but not canonical fail withNonCanonicalArguments.
The verdict side is the mirror image. The kernel returns a VerdictResult, which the adapter lowers back into the provider's tool-result shape.
pub enum VerdictResult {
Allow {
redactions: Vec<Redaction>,
receipt_id: ReceiptId,
},
Deny {
reason: DenyReason,
receipt_id: ReceiptId,
},
}pub enum DenyReason {
PolicyDeny { rule_id: String },
GuardDeny { guard_id: String, detail: String },
CapabilityExpired,
PrincipalUnknown,
BudgetExceeded,
}A Redaction is a JSON Pointer path plus a replacement string, applied to the tool result on the allow path; a path that does not start with / is refused. Two limits to know. The streaming path rejects an allow that carries redactions outright rather than editing frames mid-stream. And the kernel-side conversion always returns an empty redaction list, so redactions on this type are what an adapter or a caller supplies, not something the kernel populates.
Three opaque byte wrappers keep wire-format handling in the adapter: ProviderRequest (upstream request bytes an adapter lifts), ProviderResponse (bytes lower hands back to the transport), and ToolResult (canonical-JSON tool output for consistent downstream verification).
Anthropic Messages
chio-anthropic-tools-adapter is a mediation gateway. It owns the live transport to api.anthropic.com: send_messages forwards a native messages.create body to /v1/messages with the x-api-key and pinned anthropic-version: 2023-06-01 headers, then lifts every tool_use content block in the response.
[dependencies]
chio-anthropic-tools-adapter = "0.1"
chio-tool-call-fabric = "0.1"
chio-kernel = "0.1"
chio-manifest = "0.1"
tokio = { version = "1", features = ["full"] }use std::sync::Arc;
use chio_anthropic_tools_adapter::{
anthropic_transport_from_env, AnthropicAdapter, AnthropicAdapterConfig,
};
use chio_tool_call_fabric::{ToolResult, VerdictResult};
// ::new pins api_version to ANTHROPIC_VERSION ("2023-06-01"). workspace_id
// populates Principal::AnthropicWorkspace on every emitted ProvenanceStamp.
let config = AnthropicAdapterConfig::new(
"anthropic-front", // server_id
"Anthropic Messages", // server_name
"1.0.0", // server_version
std::env::var("CHIO_SERVER_PUBLIC_KEY")?, // hex Ed25519 public key
"wks_prod", // Anthropic workspace id
);
// Reads x-api-key from ANTHROPIC_API_KEY.
let transport = anthropic_transport_from_env()?;
let adapter = AnthropicAdapter::new(config, Arc::new(transport));
// Forward the messages.create body; lift every tool_use block in the reply.
let invocations = adapter.send_messages(&request_body).await?;
for invocation in &invocations {
invocation.validate()?; // fail closed before the kernel
let verdict: VerdictResult = kernel_verdict(invocation);
// Lower the verdict + executed output into a tool_result content block
// for the next user turn. The request_id is the tool_use id (toolu_...).
let block = adapter.lower_tool_result_block(
&invocation.provenance.request_id,
verdict,
ToolResult(tool_output_json.clone()),
)?;
// block.is_error is true on deny; its content carries the DenyReason.
}On the deny path the adapter emits a tool_result block with is_error: true whose content describes the DenyReason. The model receives the denial; the tool backend is not reached. If you drive the batch path yourself, lift_batch takes a ProviderRequest holding a response payload and returns the same Vec<ToolInvocation>.
Server Tools Need a Dual Gate
Anthropic's hosted server tools (computer_use, bash, and text_editor, plus their date-suffixed wire names) have more authority than client-hosted tools. The adapter fails closed on any of them unless both gates are open: the computer-use cargo feature must be compiled in (it also adds the anthropic-beta: computer-use-2025-01-24 header), and the tool manifest's server_tools list must name the matching stable entry. The feature alone does not admit a call.
| Anthropic wire name | Manifest entry |
|---|---|
computer_use, computer_use_YYYYMMDD | computer_use |
bash, bash_YYYYMMDD | bash |
text_editor, text_editor_YYYYMMDD | text_editor |
The mapping folds any 8-digit date suffix onto the bare name, so a wire version bump from bash_20241022 to a later date stays behind the same allowlist entry. Build the gate from a validated manifest with AnthropicAdapter::new_with_manifest; the default AnthropicAdapter::new starts with a deny-all gate. Custom tool names Anthropic does not recognize as server tools skip the gate entirely.
Deny-all is the default
new and every server-tool call fails closed, even with the computer-use feature compiled. The manifest is the authority: only tools named in server_tools pass ensure_tool_allowed.Amazon Bedrock Converse
chio-bedrock-converse-adapter drives the Bedrock Runtime Converse operation through the AWS SDK for Rust, SigV4-signed, and lifts every toolUse content block in the response. The v1 API is pinned: BEDROCK_REGION is us-east-1 and BEDROCK_CONVERSE_API_VERSION is bedrock.converse.v1. BedrockAdapterConfig::validate and BedrockAdapter::new reject any other region or API version at construction, and the transport re-checks the region on every call.
use std::sync::Arc;
use aws_config::BehaviorVersion;
use chio_bedrock_converse_adapter::{
AwsSdkTransport, BedrockAdapter, BedrockAdapterConfig, ConverseRequest,
DEFAULT_IAM_PRINCIPALS_CONFIG_PATH,
};
use chio_tool_call_fabric::{ToolResult, VerdictResult};
// ::new pins region to us-east-1 and api_version to bedrock.converse.v1.
let config = BedrockAdapterConfig::new(
"bedrock-front",
"Bedrock Converse",
"1.0.0",
std::env::var("CHIO_SERVER_PUBLIC_KEY")?,
"arn:aws:iam::123456789012:role/ChioAgentRole", // overwritten on the signed path
"123456789012",
);
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
.region("us-east-1")
.load()
.await;
let transport = AwsSdkTransport::from_sdk_config(&sdk_config)?;
// Production init resolves the IAM caller from a signed map before any
// tool traffic can be lifted (see below).
let adapter = BedrockAdapter::new_with_signed_iam_principals_config_from_sts(
config,
Arc::new(transport),
&sts_provider,
DEFAULT_IAM_PRINCIPALS_CONFIG_PATH,
&verifier,
&expected_identity,
).await?;
// Drive one Converse turn and lift every toolUse block.
let request = ConverseRequest::new("anthropic.claude-3-5-sonnet-20241022-v2:0", messages);
let invocations = adapter.converse(request).await?;
for invocation in &invocations {
let verdict: VerdictResult = kernel_verdict(invocation);
// response.0 is Bedrock JSON: { "toolResult": { toolUseId, content, status } }
let response = adapter.lower_tool_result(
&invocation.provenance.request_id, // toolUseId
verdict,
ToolResult(tool_output_json.clone()),
)?;
}If the Converse response carries a toolConfig, lift_batch enforces it: any lifted tool name must be declared in that config, or the payload fails closed as Malformed. On the allow path lower_tool_result applies JSON Pointer redactions and wraps the output as Bedrock content; on the deny path it emits a structured toolResult with status: "error" carrying a chio denial payload (verdict, receipt id, reason).
Resolve the IAM Caller
Bedrock has no per-request principal header the way Anthropic has a workspace. The adapter resolves the calling IAM identity from a signed map before any tool-use traffic is lifted. new_with_signed_iam_principals_config_from_sts resolves STS GetCallerIdentity once per process, loads config/iam_principals.toml, verifies the Sigstore bundle beside it (the TOML path with .sigstore-bundle.json appended, per sigstore_bundle_path) through the chio-attest-verify verifier, and maps the caller ARN to the shared Principal::BedrockIam shape.
# iam_principals.toml
# Default signed-config path for Bedrock IAM principal mapping.
# Replace these sample mappings with operator-owned values and publish a
# matching Sigstore bundle at iam_principals.toml.sigstore-bundle.json.
default_action = "deny"
config_version = 1
[[mapping]]
match = "arn:aws:iam::123456789012:role/ChioAgentRole"
owner = "team-alpha"
notes = "sample exact role mapping"
[[mapping]]
match = "arn:aws:sts::123456789012:assumed-role/ChioAgentRole/*"
owner = "team-alpha"
notes = "sample assumed-role session mapping"The mapping list is ordered and the first match wins; each match is an exact ARN or a * wildcard pattern. Loading rejects a config_version other than 1, a default_action other than deny, an empty mapping list, and any entry with a blank match or owner (crates/protocol/chio-bedrock-converse-adapter/src/iam_principals.rs:272-300). The shipped file is a sample: replace the mappings with operator-owned values and publish a matching Sigstore bundle beside it.
Resolution fails closed on a missing config file, a missing Sigstore bundle, a rejected signature, invalid TOML, an unsupported config_version, or a caller ARN with no matching entry. For an STS assumed-role caller the adapter keeps the session ARN in assumed_role_session_arn and stores the canonical role ARN separately in caller_arn. The resolved owner and the matched pattern are available via principal_owner() and matched_iam_principal_pattern().
Related: workload identity
Streaming Verdicts
The streaming state machine lives in chio-tool-call-fabric alongside the types above, and both adapters drive it. It gates tool-call blocks at the block boundary: frames are buffered while the kernel resolves a verdict, then flushed on allow or dropped on deny. StreamPhase walks Idle → Buffering → Emitting → Closed. Each adapter maps its upstream events onto StreamEvent: Anthropic content_block_start and content_block_stop, Bedrock contentBlockStart and contentBlockStop, with the delta events in between accumulating bytes. The completed block is evaluated before any of its bytes are released.
// Anthropic SSE: buffer each tool_use block through content_block_stop,
// evaluate it, and release its bytes only if the verdict allows.
let gated = adapter.gate_sse_stream(&sse_bytes, |invocation| {
Ok(kernel_verdict(invocation)) // Result<VerdictResult, ProviderError>
})?;
// gated.bytes: SSE frames that are safe to forward downstream.
// gated.invocations: the tool_use blocks evaluated, in stream order.
// gated.verdicts: the verdict returned for each, in the same order.
// Bedrock is the mirror image: gate_converse_stream buffers each block
// from contentBlockStart through contentBlockStop and releases it on
// allow, returning a GatedConverseStream. That struct adds a fourth
// field, "events", which GatedSseStream does not have.Buffering is bounded so a runaway upstream cannot pin unbounded heap while a verdict is pending. A single block is capped at DEFAULT_MAX_BUFFERED_BLOCK_BYTES (1 MiB) and DEFAULT_MAX_BUFFERED_RAW_FRAMES (4096 frames); an overflow fails the stream rather than continuing to buffer. The verdict budget itself is the caller's: the adapters do not time your evaluator. What they do is preserve the ProviderError::VerdictBudgetExceeded your evaluator returns, with its observed_ms and budget_ms, and fail the stream closed on it. A slow verdict cannot become an allow.
The Error Taxonomy Is Shared
Both adapters map upstream failures to the ProviderError taxonomy in chio-tool-call-fabric. It has seven named classes plus a catch-all, and Malformed is the seventh rather than an eighth fallback: anything the adapter cannot place lands there and fails closed.
pub enum ProviderError {
#[error("rate limited by upstream: retry after {retry_after_ms}ms")]
RateLimited { retry_after_ms: u64 },
#[error("upstream content policy denied request: {0}")]
ContentPolicy(String),
#[error("tool arguments failed schema validation: {0}")]
BadToolArgs(String),
#[error("upstream 5xx ({status}): {body}")]
Upstream5xx { status: u16, body: String },
#[error("transport timeout after {ms}ms")]
TransportTimeout { ms: u64 },
#[error("verdict latency budget exceeded ({observed_ms}ms > {budget_ms}ms); fail-closed")]
VerdictBudgetExceeded { observed_ms: u64, budget_ms: u64 },
#[error("malformed upstream payload: {0}")]
Malformed(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}Other is never produced by either adapter, and that is structural rather than a convention: the variant wraps an anyhow::Error, and neither adapter crate depends on anyhow at all. Read the remaining seven with their real triggers, which are narrower than their names suggest.
| ProviderError | Meaning |
|---|---|
RateLimited | Upstream throttling: HTTP 429 on the Anthropic path, ThrottlingException on the Bedrock path. Its retry_after_ms is a constant, 0 for Anthropic and 1000 for Bedrock. Neither adapter reads a provider-supplied retry hint, and the Anthropic transport does not surface response headers at all, so do not treat the number as advice from the provider |
ContentPolicy | HTTP 403 from the provider, and only on the Anthropic path. A refusal returned inside a 200 response is not parsed into this variant, and the Bedrock adapter has no code path that produces it: a guardrail intervention arrives as some other class |
BadToolArgs | A tool_use.input or toolUse.input that cannot become canonical JSON object arguments. Also any non-403 4xx on the HTTP path, and a malformed tool result on the Bedrock lower path |
Upstream5xx | Any status in the 500 range, which is how an overload response lands here: the status is what is read, not the envelope. Bedrock also routes ModelTimeoutException into this variant, carrying status 408 |
TransportTimeout | Local per-call timeout, classified separately from upstream timeout envelopes |
VerdictBudgetExceeded | Your evaluator reported that it missed its own budget. The adapter preserves it with observed_ms and budget_ms and fails the stream closed; it does not measure the budget itself |
Malformed | Impossible or out-of-order native payloads; the fail-closed default |
Detached Provenance
The Chio receipt is signed, but the ProvenanceStamp alone is not separately attestable from the receipt. When an auditor needs to verify the upstream identity (provider, request id, principal) without pulling the surrounding receipt, use detached signing. sign_provenance serializes the stamp to RFC 8785 canonical JSON, signs those bytes, and returns a SignedProvenance envelope carrying the stamp, the signed bytes, the algorithm, the public key, and the detached signature.
use chio_tool_call_fabric::{sign_provenance, verify_signed_provenance};
// Sign the stamp with the adapter's signing backend.
let envelope = sign_provenance(&invocation.provenance, &backend)?;
// verify_signed_provenance runs three checks in order:
// 1. envelope.algorithm matches the signature algorithm,
// 2. envelope.signed_bytes re-canonicalize from stamp byte-for-byte,
// 3. the signature verifies against public_key over signed_bytes.
// On success it returns the public key that signed the stamp.
let signer = verify_signed_provenance(&envelope)?;The envelope is itself canonical-JSON serializable, so it can be embedded in an audit log line, attached to a receipt, or transmitted as a standalone attestation. All cryptography is delegated to the same primitives used for capability tokens and receipts; the adapter adds no new key handling.
Same Kernel, Same Receipts
At the kernel boundary one module, provider_verdict, turns a validated ToolInvocation into the kernel's internal request and turns the kernel's decision back into a VerdictResult. The entry point you call is ChioKernel::verdict_for_provider_invocation. This keeps provider-specific terms out of kernel internals: Anthropic and Bedrock calls use the same receipt schema, guards, capability checks and signature as the OpenAI path, and the provenance stamp is what identifies the provider.
Two conversion details worth knowing before you rely on them. The shim always returns an empty redaction list on an allow, because the fabric verdict carries no data-guard redaction detail. And a kernel PendingApproval maps to Deny on this path rather than to a third state, so an approval-gated call reads as a refusal to a provider adapter.
Pinned Contracts and Offline Replay
The lift/lower wire contract is pinned with byte-stable canonical-JSON fixtures under crates/protocol/chio-tool-call-fabric/fixtures/lift_lower/{openai,anthropic,bedrock}/, three files each, and a test asserts each one re-serializes to the same bytes. Those are the files the two transcripts earlier on this page read. A separate crate, chio-provider-conformance, keeps its own NDJSON capture corpus under crates/protocol/chio-provider-conformance/fixtures/ and replays those through each adapter behind per-provider cargo features.
The CLI surface for a capture is chio replay traffic, which reads a chio-tee-frame.v1 NDJSON stream and deserializes a ToolInvocation out of each frame. With no other flags it validates:
$ chio replay traffic \
--from ./capture.ndjsonchio replay traffic: validating <chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/clean_match.ndjson (schema=chio-tee-frame.v1) line 1: ok (01H7ZZZZZZZZZZZZZZZZZZZZZA) chio replay traffic: 1/1 frames passed
--json gives the same run as one envelope, which is the shape to assert on in CI:
$ chio replay traffic \
--from ./capture.ndjson --json{"first_error":null,"from":"<chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/clean_match.ndjson","ok":true,"passes":1,"schema":"chio-tee-frame.v1","total":1}Validation is not verification of a verdict. --against <policy-path> is what re-executes each captured call in an ephemeral kernel and compares the recomputed verdict against the captured one. The policy is the one you name, not one recorded in the frame, so a replay answers what would this policy have done, not what did the original policy do. That path additionally refuses a policy carrying post-output guards or concrete-server grants, because a pre-output replay cannot honor them.
A frame's upstream.system is a closed set of six: openai, anthropic, aws.bedrock, mcp, a2a and acp. The five providers that use the vocabulary without implementing the trait have no frame system of their own, so a capture from one of them has nowhere to say so.
Both refusals are worth seeing, because they are the two ways a capture goes wrong. A frame that does not parse:
$ chio replay traffic \
--from ./malformed.ndjsonchio replay traffic: validating <chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/parse_error.ndjson (schema=chio-tee-frame.v1) line 1: FAIL exit=30 ndjson parse error on line 1: key must be a string at line 1 column 2 chio replay traffic: 0/1 frames passed
And a schema name that does not match, which is the gate that stops a capture from a different frame version being read as this one:
$ chio replay traffic \
--from ./capture.ndjson --schema chio-tee-frame.v2chio replay traffic: validating <chio-source>/crates/products/chio-cli/tests/replay_traffic/fixtures/clean_match.ndjson (schema=chio-tee-frame.v2) line 1: FAIL exit=40 schema-version gate failed: expected schema name "chio-tee-frame.v1", got "chio-tee-frame.v2" chio replay traffic: 0/1 frames passed
Verify the Result
Four checks. The first three need no provider credentials.
- The adapter constructs against your pinned values.
BedrockAdapter::newreturns aResultand rejects any region or API version other than the pinned pair at construction, so a misconfigured deployment fails at boot rather than on first traffic. The Anthropic config pins its API version the same way. - A lifted invocation validates. Call
invocation.validate()before the kernel, every time, including on a replay path. A mismatched principal variant or non-canonical argument bytes are the two failures worth asserting on in a test. - A capture replays. Run
chio replay traffic --from <capture> --jsonover a recorded stream and assertokandpasses. Add--againstwhen you want the verdict compared and not just the frame parsed. - A server-tool call is refused without its manifest entry. On the Anthropic path, construct with
newrather thannew_with_manifestand confirm every hosted-tool call fails closed even with thecomputer-usefeature compiled. That is the gate you most want to be sure of.
Failures and Recovery
| What you see | What it means, and what to do |
|---|---|
NonCanonicalArguments on a value you serialized yourself | The bytes are JSON but not RFC 8785 canonical form, most often unsorted keys. Re-serialize canonically. If the bytes are not JSON at all you get InvalidArgumentJson instead, which is a different bug. |
| A validation failure naming the principal | The Principal variant does not match provenance.provider, or one of its own string fields is empty, padded with whitespace, or carries a control character. All of those fail closed by design. |
| Bedrock construction refuses before any traffic | The region or API version is not the pinned pair. The transport re-checks the region on every call as well, so overriding it later does not help. |
| IAM principal resolution fails at startup | Six conditions fail closed: a missing config file, a missing Sigstore bundle, a rejected signature, invalid TOML, an unsupported config_version, and a caller ARN with no matching entry. An empty mapping list is refused too. The bundle is verified before the TOML is parsed, so a signature problem masks any content problem behind it. |
| A hosted server tool fails closed with the feature compiled | The manifest is the second gate and the authoritative one. Build the adapter with new_with_manifest and name the stable entry in server_tools. |
| A stream fails instead of forwarding an allowed block | Either the block exceeded the buffer caps, or the allow carried redactions. The streaming path refuses a redaction-bearing allow rather than editing frames mid-stream; apply redactions on the non-streaming path. |
A Bedrock tool name comes back Malformed | The response carried a toolConfig and the lifted name was not declared in it. That is the lift refusing to admit a tool the turn never offered. |
| An approval-gated call reads as a plain deny | The kernel-to-fabric conversion maps PendingApproval onto Deny. Read the receipt if you need to tell the two apart. |
Next Steps
- Govern OpenAI Tool Calls · the shared execution model for OpenAI
- Receipts and the Receipt Format reference · the signed receipt produced for provider calls
- Capabilities · the scope model that decides which tools a token can invoke
- Write a Policy · the guards that evaluate a lifted invocation, provider-agnostic
- Bridge Between Protocols · route a lifted tool call to an MCP, A2A, or ACP backend