Chio/Docs
LOGIN · JOIN

BuildIdentity

Delegate Between Agents

Give another agent a narrowed capability token with a signed delegation link.

What delegation adds

Delegation appends a signed link to a capability token. The link names the delegator and delegatee and records the scope reduction. The kernel validates the complete chain when the delegatee presents the token.

Why Delegate

A supervisor agent often needs to fan work out to a pool of subagents without handing each subagent all of the supervisor's authority. You have three options:

  • Ask the Capability Authority for a fresh token per subagent. Correct, but adds a round trip and requires the CA to be available and to know about every subagent ahead of time.
  • Share the supervisor's token. Do not do this. A token names one subject, and the kernel compares the presented agent id against it on every request: check_subject_binding raises KernelError::SubjectMismatch when they differ (crates/kernel/chio-kernel/src/request_matching.rs:194-207). That check is unconditional. DPoP is a separate, opt-in proof requirement carried per grant as dpop_required, and None and Some(false) both mean no proof is required (crates/core/chio-core-types/src/capability/scope.rs:114-117).
  • Delegate. The supervisor signs a new delegation link that narrows scope, binds the subagent's public key as the new subject, and hands back a self-contained token the subagent can present directly to the kernel.

Use delegation to apply least privilege across a multi-hop call graph: supervisor to worker, orchestrator to tool-calling agent, operator to short-lived automation, or a long-lived agent to a per-task token.


How Delegation Works

A capability token carries an ordered delegation_chain of DelegationLink records. Each link is a signed statement: delegator granted a narrowed capability to delegatee at timestamp, applying the listed attenuations.

rust
pub struct DelegationLink {
    /// Capability ID of the ancestor token delegated at this step.
    pub capability_id: String,
    /// Public key of the agent that delegated.
    pub delegator: PublicKey,
    /// Public key of the agent that received the delegation.
    pub delegatee: PublicKey,
    /// How the scope was narrowed in this delegation step.
    pub attenuations: Vec<Attenuation>,
    /// Unix timestamp of the delegation.
    pub timestamp: u64,
    /// Chain-binding: SHA-256 hash of the scope authorized at this hop.
    /// The next hop's attenuation proof must carry a parent_scope_hash
    /// equal to this value, which stops a child from inflating the scope
    /// it inherited. Absent on older links; enforced under a feature gate.
    pub scope_hash: Option<ScopeHash>,
    /// Ed25519 signature by the delegator over the canonical body.
    pub signature: Signature,
}

When a token is presented, the kernel calls validate_delegation_chain which walks the chain and enforces four structural properties:

  • Each link's Ed25519 signature verifies against the declared delegator public key.
  • Adjacent links are connected: link[i].delegatee must equal link[i+1].delegator. No gaps, no forks.
  • Timestamps are non-decreasing across the chain. Back-dated links are rejected as a broken chain.
  • The chain length does not exceed the configured max_depth. Over the limit returns DelegationDepthExceeded.

Structural validity is not enough. The kernel also evaluates the Attenuation entries on each link against the effective scope so far, and refuses any link that widens authority.

Each link also carries a scope_hash: the SHA-256 hash of the scope authorized at that hop. The trust-root-aware validator (validate_delegation_chain_with_trust_root, feature-gated) binds every hop to the next by requiring the child's attenuation_proof.parent_scope_hash to equal the parent link's scope_hash, and rejects a chain whose hops omit it. Without that binding a token could present an internally consistent attenuation witness that was never tied to the scope its parent actually held, which is the parent-scope-inflation attack the protocol spec calls out.


Issuing a Delegated Token

You delegate from an agent, not from the CA. The supervisor already holds a token with Operation::Delegate in its grant. Use the delegate() helper, which validates and signs the hop in one call. It is gated behind the delegation feature.

rust
use chio_core_types::capability::attenuation::{delegate, Attenuation};
use chio_core_types::capability::scope::Operation;
use chio_core_types::delegation_receipt::ScopeAttenuation;

// Supervisor holds `parent_token` (its subject == supervisor_kp.public_key()).
let attenuation = ScopeAttenuation {
    steps: vec![
        // Drop the Delegate operation so the subagent cannot re-delegate.
        Attenuation::RemoveOperation {
            server_id: "mcp-github".into(),
            tool_name: "get_file".into(),
            operation: Operation::Delegate,
        },
    ],
    child_expires_at: Some(now + 300),   // never beyond parent.expires_at
    ..Default::default()
};

let receipt = delegate(
    &parent_token,
    &narrowed_scope,      // reduce-only against parent_token.scope
    &supervisor_kp,       // must match parent_token.subject
    &subagent_pubkey,
    attenuation,
    now,                  // signed_at (rejected if < parent.issued_at)
    nonce,                // [u8; 16], disambiguates same-second receipts
)?;

// receipt.link is the freshly-signed DelegationLink for this hop;
// receipt.complete_chain() is the full chain to attach to the child token.

delegate() refuses to emit a receipt whose signed link and child scope disagree. Before it signs, it verifies the parent's signature, confirms delegator_keypair matches parent.subject, rejects a signed_at earlier than the parent's issued_at, and validates the child scope and every attenuation step as reduce-only against the parent scope, expiry, and budget share. It returns a signed DelegationReceipt.

delegate() wraps the lower-level primitives. When you need explicit control over link and token assembly, construct them by hand: build a DelegationLinkBody, sign it with your own keypair, append the link to the existing chain, and produce a new CapabilityToken bound to the subagent's subject key. The manual path leaves the checks above to the caller, and it also leaves you responsible for every field on both structs: serde defaults do not apply to a Rust struct literal, so the three optional fields on DelegationLinkBody and the one on CapabilityTokenBody have to be written out.

rust
use chio_core_types::capability::attenuation::{
    Attenuation, DelegationLink, DelegationLinkBody,
};
use chio_core_types::capability::scope::{MonetaryAmount, Operation};
use chio_core_types::capability::token::{CapabilityToken, CapabilityTokenBody};

// Supervisor has `parent_token` with Operation::Delegate on a tool grant.
let link_body = DelegationLinkBody {
    capability_id: parent_token.id.clone(),
    delegator: supervisor_kp.public_key(),
    delegatee: subagent_pubkey.clone(),
    attenuations: vec![
        // Drop the Delegate operation so the subagent cannot re-delegate.
        Attenuation::RemoveOperation {
            server_id: "mcp-github".into(),
            tool_name: "get_file".into(),
            operation: Operation::Delegate,
        },
        // Tighten the expiry to the length of the sub-task.
        Attenuation::ShortenExpiry { new_expires_at: now + 300 },
        // Give the subagent only a slice of the parent budget.
        Attenuation::ReduceTotalCost {
            server_id: "mcp-github".into(),
            tool_name: "get_file".into(),
            max_total_cost: MonetaryAmount::usd_cents(500),
        },
    ],
    timestamp: now,
    scope_hash: None,          // set it to bind this hop to the next
    aggregate_budget: None,
    cumulative_approval: None,
};
let link = DelegationLink::sign(link_body, &supervisor_kp)?;

let mut chain = parent_token.delegation_chain.clone();
chain.push(link);

let child_body = CapabilityTokenBody {
    id: child_capability_id,       // your own identifier for the child
    issuer: supervisor_kp.public_key(),
    subject: subagent_pubkey,
    scope: narrowed_scope,         // must be a subset of parent_token.scope
    issued_at: now,
    expires_at: now + 300,         // never exceed parent expires_at
    delegation_chain: chain,
    aggregate_invocation_budget: None,
};
let child_token = CapabilityToken::sign(child_body, &supervisor_kp)?;

scope_hash: None is the compatibility path, not the safe one

A link with no scope_hash cannot be bound to its child, which is the parent-scope-inflation gap described above. validate_delegation_chain says so in its own doc comment and points callers at validate_delegation_chain_with_trust_root (crates/core/chio-core-types/src/capability/attenuation.rs:212-224). Prefer delegate(), which fills the field for you.

The kernel uses validate_attenuation to confirm that child_body.scope is a subset of the effective parent scope. If the child carries a grant, operation, or constraint that is not present in the parent, the kernel returns AttenuationViolation and the request is denied. No tool call fires.

Delegate requires the Delegate operation

A token without Operation::Delegate on a grant cannot delegate that grant. If you want a subagent to be able to fan out further, you must leave Delegate on the grant. If you want the chain to stop at the subagent, attenuate Delegate off before handing the token over.

Attenuation Rules

An attenuation can only narrow scope. TheAttenuation enum lists the allowed reductions. Any other change, including a scope expansion, is a protocol violation.

AttenuationNarrowsNotes
RemoveToolDrops a tool grant entirelySubagent cannot call the tool at all
RemoveOperationDrops one operation from a grantCommon: strip Delegate to end the chain
AddConstraintAdds a policy constraint to a grantFor example, a tighter path allowlist or autonomy tier
ReduceBudgetLowers max_invocationsNo greater than the parent cap; an uncapped parent accepts any finite child cap
ShortenExpirySets a closer expires_atMust be on or before the parent expiry
ReduceCostPerInvocationTightens max_cost_per_invocationPer-call cost cap, monetary
ReduceTotalCostCarves a sub-budget from the parentSame currency, no greater than the parent cap

Every row is checked by validate_attenuation_step (crates/core/chio-core-types/src/capability/attenuation.rs:449-556), and the two monetary rows share one predicate, cost_narrows, which requires the same currency and a value no greater than the parent's (:563-571).

One step cannot see its siblings

ReduceTotalCost compares one child cap against one parent cap. It cannot stop two children from each taking a legal slice that together exceed the parent, because it never sees the other child. That is a separate mechanism: BudgetRegistry tracks each parent's share in basis points and admits children against the remainder, failing closed on an unknown parent (crates/kernel/chio-kernel-core/src/budget_split.rs:401-424). A deployment wired to NoopBudgetRegistry gets no sibling-oversubscription check at all (crates/kernel/chio-kernel-core/src/budget_split.rs:459-471).

The protocol pins this rule as safety property P1 capability attenuation: supported delegated capability issuance can only narrow scope relative to the issuing parent. A child token whose scope is not a subset of its parent is not a valid Chio capability, and the kernel denies it during capability verification, before any guard runs or any side effect occurs.

Scope subset check

The validate_attenuation(parent, child) helper uses ChioScope::is_subset_of. If you need to compute a narrowed scope programmatically, apply attenuations to a clone of the parent scope and feed the result into the same check. Do not hand-build the child scope independently of the parent.

Delegation Depth Limits

validate_delegation_chain(chain, max_depth) accepts an optional maximum depth. Operators set this in kernel configuration as max_delegation_depth, which defaults to 5 (crates/platform/chio-config/src/schema.rs:47, applied at :73). Keep the bound small, because longer chains raise three concerns:

  • Each hop narrows the effective scope. Deep chains make incident investigation harder.
  • Revocation has to traverse the entire ancestry, so longer chains cost more on every presentation.
  • Deep chains usually indicate a control-flow problem: a long-lived agent that should have asked the CA for a fresh token is instead re-delegating from a months-old root.

Exceeding the bound raises Error::DelegationDepthExceeded{ depth, max } and the kernel fails closed.


Lineage & Receipts

Each invocation produces a signed receipt that records the delegation context of its authorizing token. Auditors can use this lineage to determine who delegated which authority.

Lineage is persisted as one NDJSON row per capability, and chio evidence export writes it to capability-lineage.ndjson inside the package. This is a delegated row from a committed evidence package, with the embedded signed_capability dropped so the record fits:

federated-delegation · lineagetranscript
$ jq 'del(.signed_capability)' \
    evidence-export-package/capability-lineage.ndjson
{
  "capability_id": "cap-ioa-web3-provider",
  "subject_key": "bff663535a5cf58658cc38595d20dc4501f6dfc076ddf07e20571d413a3ceb5a",
  "issuer_key": "a090e1a43efcd3a3bd5ea5b015e6014d20e33e0e2d480194802720e4d38f108b",
  "issued_at": 1776995579,
  "expires_at": 1776996779,
  "grants_json": "{\"grants\":[{\"constraints\":[],\"operations\":[\"invoke\"],\"server_id\":\"provider-review\",\"tool_name\":\"inspect_service_order\"},{\"constraints\":[],\"operations\":[\"invoke\"],\"server_id\":\"provider-review\",\"tool_name\":\"evaluate_provider_reputation\"},{\"constraints\":[],\"operations\":[\"invoke\"],\"server_id\":\"provider-review\",\"tool_name\":\"issue_review_attestation\"}],\"prompt_grants\":[],\"resource_grants\":[]}",
  "delegation_depth": 1,
  "parent_capability_id": "cap-019dbd30-b376-7b92-ba54-d00aec973369"
}
exit 0

delegation_depth is 1 and parent_capability_id names the hop above, which is what an auditor walks. Both keys are bare lowercase hex. grants_json is the scope serialized as a string rather than as a nested object, so a reader has to parse it a second time: the row is the persisted shape, not the Rust CapabilityLineageRecord, whose matching field is a typed scope: ChioScope (crates/trust/chio-reputation/src/model.rs:9-18).

Two invariants hold across a chain of these rows. First, the subject_key of the parent equals the issuer_key of the child: the supervisor is the subject of its own token and the issuer of the subagent's token. Second, expires_at shrinks as you descend. The child cannot outlive its parent.

For governed transactions, receipts also carry a call_chain block. The fields that travel with a delegated governed request are:

  • chain_id: stable identifier for the delegated transaction or call chain
  • parent_request_id and parent_receipt_id: link into the prior hop's audit trail
  • origin_subject: the root delegator visible in capability lineage
  • delegator_subject: the immediate delegator that handed control to the current subject

Financial receipt metadata also includes delegation_depth and root_budget_holder, so cost attribution follows the cryptographic chain without a separate ledger.

Asserted vs. verified provenance

Chio distinguishes asserted from verified call-chain context. A subagent can assert what chain it thinks it is on; the kernel only marks the chain as verified when it observed the local parent edge, when a receipt-lineage statement signed by a trusted kernel exists, or when an upstream handoff proof verifies against the capability's delegator key. Reports never label asserted lineage as verified (safety property P10).

Revocation Cascade

Revocation in Chio is ancestry-aware. When the kernel evaluates a presented token, it checks revocation state for the presented capability id and for every ancestor id referenced in the delegation_chain. This is safety property P2 presented revocation coverage: a revoked capability, or a revoked presented delegation ancestor id, is denied.

Revoking a parent denies child tokens derived from it without requiring the CA to enumerate them. Revoke the parent token to deny its descendants.

bash
# Revoke the supervisor's root capability. All subagent tokens derived
# from it start failing at the next kernel verification, regardless of
# how deep in the delegation chain they sit.
chio --revocation-db revocations.sqlite3 \
  trust revoke \
  --capability-id cap-019d93c6-924d-70b0-be41-4f9c3c7f5a0b
chio trust revoke stdoutbash
capability_id: cap-019d93c6-924d-70b0-be41-4f9c3c7f5a0b
revoked:       true
newly_revoked: true
backend:       revocations.sqlite3

One row, one write. The command says nothing about descendants because nothing is written for them: the cascade is evaluated at admission, by walking the presented chain against this same store.

A subagent that re-presents its token after the parent is revoked receives a denied verdict during capability validation. The revocation check runs at crates/kernel/chio-kernel/src/kernel/evaluation/async_evaluation_core.rs before the guard pipeline in the same function, and so before any tool server is contacted. A leaf revocation raises KernelError::CapabilityRevoked; a revoked ancestor raises KernelError::DelegationChainRevoked, naming the ancestor id rather than the presented one.

Rotate Keys & Revoke carries the full transcript of one capability allowed, revoked, and then refused, with the deny receipt the refusal writes.

Plan for cascade

Before you revoke a token mid-flight, consider which children depend on it. Cascade is the right default for security incidents. For planned rotations, issue the new root first, migrate subagents to tokens derived from the new root, and only then revoke the old root. See Rotate Keys & Revoke for the full runbook.

Common Patterns

Supervisor and Subagent

The supervisor holds a long-lived token and fans out work to short-lived subagents. Each subagent gets a token that is tool-scoped to the tools it needs, scoped to the sub-task duration, and has Delegate attenuated off so the chain stops there.

A Python supervisor computes the narrowed scope with the same typed models the kernel validates against. pip install chio-sdk-python gives you chio_sdk, which exports ChioScope, ToolGrant, Operation, Constraint, and Attenuation:

python
from chio_sdk import Attenuation, ChioScope, Operation, ToolGrant

# What the supervisor holds: two tools, and the right to delegate one of them.
parent_scope = ChioScope(
    grants=[
        ToolGrant(
            server_id="mcp-observability",
            tool_name="query_spans",
            operations=[Operation.invoke, Operation.delegate],
        ),
        ToolGrant(
            server_id="mcp-observability",
            tool_name="write_span",
            operations=[Operation.invoke],
        ),
    ]
)

# What the subagent gets: one tool, invoke only, so the chain stops here.
child_scope = ChioScope(
    grants=[
        ToolGrant(
            server_id="mcp-observability",
            tool_name="query_spans",
            operations=[Operation.invoke],
        )
    ]
)

step = Attenuation.remove_tool(
    server_id="mcp-observability",
    tool_name="write_span",
)

Attenuation.remove_tool serializes to the wire shape the kernel reads:

step.model_dump(exclude_none=True)json
{"type": "remove_tool", "server_id": "mcp-observability", "tool_name": "write_span"}

The reduce-only rule is enforced before anything leaves the process. Hand a scope that adds a grant the parent never had and the client refuses it locally, with no round trip to check against:

python
wider = ChioScope(
    grants=parent_scope.grants
    + [ToolGrant(server_id="mcp-github", tool_name="get_file", operations=[Operation.invoke])]
)

await chio.attenuate_capability(parent_token, new_scope=wider)
stdoutbash
ChioValidationError: new_scope must be a subset of the parent token scope

Signing the hop is a separate step from computing it, and it happens where the parent subject key lives. The sidecar route is a control boundary rather than a signer: minting a child token requires the parent subject signer, which the sidecar must not hold. So the supervisor signs with delegate() as shown above, and the narrowed scope it passes is the one this block computed.

Per-Task Capability

A long-lived agent that processes a queue of heterogeneous tasks can mint a per-task capability from its root token before running each task. The per-task token carries only the tools and budget that specific task needs, with an expiry that bounds task duration. A leaked token, including one exposed through a prompt-injected tool call or compromised subprocess, remains scoped to that task.

Human-Approval Step

For sensitive operations, the root token can require MinimumAutonomyTier(Delegated) as a constraint. The agent delegates to itself with a governed approval token attached, bound to a specific intent hash. The kernel treats Delegated as requiring a delegation bond on the governed request, which surfaces the call for operator review before the tool fires.

Cross-Org Handoff

When delegating across organizational boundaries, combine the capability token with an Agent Passport. The receiving side verifies the chain structurally (all the rules in this guide apply), and independently evaluates the delegating agent's passport against its own verifier policy before honoring the token.

Chio provides a federated issuance command for this. After the partner presents a challenge-bound passport, the receiving org mints a fresh capability from that presentation with chio trust federated-issue. The new token is issued under the receiver's own authority and capability policy, not carried across as a raw delegation link.

bash
# Receiving org: issue a capability from a verified partner presentation
chio trust federated-issue \
  --presentation-response presentation.json \
  --challenge challenge.json \
  --capability-policy capability-policy.yaml \
  --enterprise-identity partner-identity.json \
  --delegation-policy signed-delegation-policy.json \
  --upstream-capability-id cap-partner-7b0f

The --delegation-policy file is a signed file the issuing partner creates with chio trust federated-delegation-policy-create. It names the issuer, the partner, the verifier endpoint, and the capability policy that bounds what the partner may request, with an explicit expiry.

bash
# Issuing org: publish a signed federated delegation policy
chio trust federated-delegation-policy-create \
  --output signed-delegation-policy.json \
  --signing-seed-file issuer.seed \
  --issuer did:chio:issuer... \
  --partner acme-corp \
  --verifier https://verify.acme.example \
  --capability-policy capability-policy.yaml \
  --expires-at 1776302557

CrewAI Multi-Agent Crews

The chio-crewai package is the supervisor/subagent pattern applied to a CrewAI crew. You map each role to a ChioScope, hand the map to a ChioCrew, then call provision_capabilities() to mint the narrowed per-role capabilities before the crew runs. Each tool call an agent attempts is then evaluated by the sidecar kernel.

python
from chio_crewai import ChioCrew
from chio_sdk.client import ChioClient
from chio_sdk.models import ChioScope

async with ChioClient("http://127.0.0.1:9090") as chio:
    crew = ChioCrew(
        capability_scope={
            "researcher": ChioScope(grants=[search_grant()]),
            "writer": ChioScope(grants=[write_grant()]),
        },
        chio_client=chio,
        agents=[researcher, writer],
        tasks=[task],
    )
    await crew.provision_capabilities()   # mint per-role scoped capabilities
    result = crew.kickoff()

Delegation between roles reuses the same reduce-only rule. The crew mints an attenuated child token with crew.attenuate_for_delegation(delegator_role, delegate_role, new_scope), and the SDK raises ChioValidationError if the new scope tries to broaden what the delegator holds.


Summary

ConceptMeaning
DelegationLinkSigned record: delegator granted a narrowed capability to delegatee at timestamp
delegation_chainOrdered list of links from the root CA to the presented token
AttenuationClosed enum of legal narrowings: remove tool or operation, add constraint, reduce budget, shorten expiry, cap cost
validate_delegation_chainChecks per-link signatures, connectivity, timestamp monotonicity, and max depth
validate_attenuationConfirms that the child scope is a subset of the parent scope
P1 attenuationSafety property: delegated issuance can only narrow, never widen
P2 revocation coverageRevoking an ancestor denies every descendant presentation
Lineage in receiptsdelegation_depth, parent_capability_id, and call-chain fields persist the hop

Next Steps

  • Rotate Keys & Revoke · the planned-rotation flow and the incident-response cascade
  • Capabilities · the underlying token model and scope structure
  • Receipts · how call-chain and lineage metadata land in signed audit evidence
  • CLI Reference · chio trust commands for issuing, delegating, and revoking tokens