Delegate Between Agents
Let the fixer hand a tester a narrower slice of its grant, then follow the tester's refused write to its receipt.
On this page
Split a task across agents and give each one only the authority its part needs. An agent can delegate part of its grant to another, the child always holds less than its parent, and revoking the parent ends the child.
Continues from the Quickstart, in the quickstart project. Issue #416: test_refund_window fails a few runs in every fifty. The fixer hands a tester read-only access to reproduce it, the tester tries to patch the code anyway, and the kernel refuses the write.
1. Give the fixer a grant it can delegate
A grant delegates only when it says so. Grant the fixer the workspace, a shell, and the model route, with one level of delegation and two children at most. Then delegate a slice to the tester: the workspace read-only, the shell, the model route, fifty cents, and ten minutes.
import { AttenuationError, Computer, USD } from "@chio-protocol/sdk";
const project = await Computer.open("quickstart");
const fixer = await project.agent("fixer");
const tester = await project.agent("tester");
const fork = await project.fork();
const authority = fork.grant({
to: fixer,
resources: {
"/workspace": ["read", "write"],
"/shell": ["run"],
"/models/default": ["invoke"],
},
delegation: { depth: 1, fanout: 2 },
budget: USD(2),
expiresIn: "30m",
});
const child = authority.delegate({
to: tester,
resources: { "/workspace": ["read"], "/shell": ["run"], "/models/default": ["invoke"] },
budget: USD("0.50"),
expiresIn: "10m",
});
console.log(child.describe());grant cap-01d2…6c13, delegated by fixer from cap-01d2…2a90
to tester, did:chio:z6MkvH5…3qe
on quickstart, fork 9c3a5e1
allows /workspace read
/shell run
/models/default invoke
budget USD 0.50, held from the fixer's USD 2.00
expires in 10 minutes
chain you, fixer, testerimport json
import chio
from chio import Computer, Delegation, USD
project = Computer.open("quickstart")
fixer = project.agent("fixer")
tester = project.agent("tester")
fork = project.fork()
authority = fork.grant(
to=fixer,
resources={
"/workspace": ["read", "write"],
"/shell": ["run"],
"/models/default": ["invoke"],
},
delegation=Delegation(depth=1, fanout=2),
budget=USD(2),
expires_in="30m",
)
child = authority.delegate(
to=tester,
resources={"/workspace": ["read"], "/shell": ["run"], "/models/default": ["invoke"]},
budget=USD("0.50"),
expires_in="10m",
)
print(child.describe())grant cap-01d2…6c13, delegated by fixer from cap-01d2…2a90
to tester, did:chio:z6MkvH5…3qe
on quickstart, fork 9c3a5e1
allows /workspace read
/shell run
/models/default invoke
budget USD 0.50, held from the fixer's USD 2.00
expires in 10 minutes
chain you, fixer, testeruse chio::{Computer, Delegation, Grant, Money, USD};
let project = Computer::open("quickstart").await?;
let fixer = project.agent("fixer").await?;
let tester = project.agent("tester").await?;
let fork = project.fork().await?;
let authority = fork.grant(
Grant::to(&fixer)
.resource("/workspace", ["read", "write"])
.resource("/shell", ["run"])
.resource("/models/default", ["invoke"])
.delegation(Delegation::depth(1).fanout(2))
.budget(USD(2))
.expires_in("30m"),
)?;
let child = authority.delegate(
Grant::to(&tester)
.resource("/workspace", ["read"])
.resource("/shell", ["run"])
.resource("/models/default", ["invoke"])
.budget(Money::cents(50))
.expires_in("10m"),
)?;
println!("{}", child.describe());grant cap-01d2…6c13, delegated by fixer from cap-01d2…2a90
to tester, did:chio:z6MkvH5…3qe
on quickstart, fork 9c3a5e1
allows /workspace read
/shell run
/models/default invoke
budget USD 0.50, held from the fixer's USD 2.00
expires in 10 minutes
chain you, fixer, testerThe child names its parent, and its fifty cents are held from the fixer's two dollars, so the two together can never spend more than the parent grant allows. The child cannot delegate again: the fixer's depth of one is used.
2. Run the tester on the flaky test
Send the tester its task under the delegated grant, on the same fork.
const run = await tester.run("find why test_refund_window flakes", { on: fork, authority: child });
for await (const event of run.events()) console.log(event.summary);00:00 admitted tester runs "find why test_refund_window flakes" on fork 9c3a5e1 00:03 run pytest tests/test_pricing.py::test_refund_window --count 50: 3 failed 00:09 read /workspace/src/pricing.py 00:12 refused write /workspace/src/pricing.py: outside the delegated grant 00:15 report the 3 failures ran at 23:59 UTC; refund() compares a naive and an aware datetime spent USD 0.09 of 0.50
run = tester.run("find why test_refund_window flakes", on=fork, authority=child)
for event in run.events():
print(event.summary)00:00 admitted tester runs "find why test_refund_window flakes" on fork 9c3a5e1 00:03 run pytest tests/test_pricing.py::test_refund_window --count 50: 3 failed 00:09 read /workspace/src/pricing.py 00:12 refused write /workspace/src/pricing.py: outside the delegated grant 00:15 report the 3 failures ran at 23:59 UTC; refund() compares a naive and an aware datetime spent USD 0.09 of 0.50
let task = tester.run("find why test_refund_window flakes").on(&fork).authority(child.clone());
let mut run = task.start().await?;
let mut events = run.events();
while let Some(event) = events.next().await {
println!("{}", event?.summary());
}00:00 admitted tester runs "find why test_refund_window flakes" on fork 9c3a5e1 00:03 run pytest tests/test_pricing.py::test_refund_window --count 50: 3 failed 00:09 read /workspace/src/pricing.py 00:12 refused write /workspace/src/pricing.py: outside the delegated grant 00:15 report the 3 failures ran at 23:59 UTC; refund() compares a naive and an aware datetime spent USD 0.09 of 0.50
You should see one refusal. The tester reproduced the failure, found the cause, and tried to fix it. The fixer's grant allows that write; the tester's does not, and the kernel checks the token the tester presents, not its parent's. Hand the report to the fixer as the next task, and the fix lands under the fixer's own grant.
3. Follow the refusal to its receipt
Ask the history for the tester's refusals, from your code or from the command line. The CLI prints each receipt as a line of JSON, with the delegation depth the kernel recorded for the grant that made the call.
const [refusal] = await project.history.receipts({ outcome: "deny", agent: "tester" });
console.log(refusal.describe());receipt rcpt-01d2…e48a request tester write /workspace/src/pricing.py verdict deny: outside the grant grant cap-01d2…6c13 chain cap-01d2…2a90 (fixer), cap-01d2…6c13 (tester) policy sha256 61d0…a7f3 signed your kernel 4f1c…a2e9, signature verifies
refusal = project.history.receipts(outcome="deny", agent="tester")[0]
print(refusal.describe())receipt rcpt-01d2…e48a request tester write /workspace/src/pricing.py verdict deny: outside the grant grant cap-01d2…6c13 chain cap-01d2…2a90 (fixer), cap-01d2…6c13 (tester) policy sha256 61d0…a7f3 signed your kernel 4f1c…a2e9, signature verifies
let refusal = project.history().receipts().outcome("deny").agent("tester").first().await?;
println!("{}", refusal.describe());receipt rcpt-01d2…e48a request tester write /workspace/src/pricing.py verdict deny: outside the grant grant cap-01d2…6c13 chain cap-01d2…2a90 (fixer), cap-01d2…6c13 (tester) policy sha256 61d0…a7f3 signed your kernel 4f1c…a2e9, signature verifies
chio receipt list --agent tester --outcome deny \
| jq -c '{id, tool: .tool_name, capability: .capability_id,
depth: .metadata.attribution.delegation_depth, reason: .decision.reason}'{"id":"rcpt-01d2…e48a","tool":"write","capability":"cap-01d2…6c13","depth":1,"reason":"not in capability scope"}The receipt names the tester's token and the chain behind it, so an auditor reading the refusal can see who delegated what to whom without asking either agent. The verdict says outside the grant: the write failed the scope check, before any rule ran.
Read more in Receipts.
4. Ask for more than the parent holds
A child can drop resources, operations, money, and time. It cannot add any. Try to delegate /secrets, which the fixer never held.
try {
authority.delegate({ to: tester, resources: { "/secrets": ["read"] } });
} catch (error) {
if (error instanceof AttenuationError) console.log(error.message);
}attenuation violation: child scope is not a subset of parent scope /secrets is not in cap-01d2…2a90; nothing was issued
try:
authority.delegate(to=tester, resources={"/secrets": ["read"]})
except chio.AttenuationError as error:
print(error)attenuation violation: child scope is not a subset of parent scope /secrets is not in cap-01d2…2a90; nothing was issued
let wider = authority.delegate(Grant::to(&tester).resource("/secrets", ["read"]));
if let Err(error) = wider {
println!("{error}");
}attenuation violation: child scope is not a subset of parent scope /secrets is not in cap-01d2…2a90; nothing was issued
Your kernel refused to sign the child, so no token exists to misuse. The same check runs again at admission on every call the tester makes, so a child token built by hand fails there too.
5. Read the narrowing the kernel checks
The child's token records each narrowing as a typed step on its delegation link. Print the chain from the token, or walk the kernel's lineage graph from the tester's grant back to its root with chio lineage query.
console.log(JSON.stringify(child.token.delegation_chain, null, 2));[
{
"capability_id": "cap-01d2…2a90",
"delegator": "b81e…40c7",
"delegatee": "5d0a…e2b1",
"attenuations": [
{ "type": "remove_tool", "server_id": "quickstart", "tool_name": "write" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "read", "operation": "delegate" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "run", "operation": "delegate" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "invoke", "operation": "delegate" },
{ "type": "reduce_total_cost", "server_id": "quickstart", "tool_name": "invoke",
"max_total_cost": { "units": 50, "currency": "USD" } },
{ "type": "shorten_expiry", "new_expires_at": 1791779400 }
],
"timestamp": 1791778800
}
]print(json.dumps(child.token.delegation_chain, indent=2))[
{
"capability_id": "cap-01d2…2a90",
"delegator": "b81e…40c7",
"delegatee": "5d0a…e2b1",
"attenuations": [
{ "type": "remove_tool", "server_id": "quickstart", "tool_name": "write" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "read", "operation": "delegate" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "run", "operation": "delegate" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "invoke", "operation": "delegate" },
{ "type": "reduce_total_cost", "server_id": "quickstart", "tool_name": "invoke",
"max_total_cost": { "units": 50, "currency": "USD" } },
{ "type": "shorten_expiry", "new_expires_at": 1791779400 }
],
"timestamp": 1791778800
}
]println!("{}", serde_json::to_string_pretty(&child.token().delegation_chain)?);[
{
"capability_id": "cap-01d2…2a90",
"delegator": "b81e…40c7",
"delegatee": "5d0a…e2b1",
"attenuations": [
{ "type": "remove_tool", "server_id": "quickstart", "tool_name": "write" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "read", "operation": "delegate" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "run", "operation": "delegate" },
{ "type": "remove_operation", "server_id": "quickstart", "tool_name": "invoke", "operation": "delegate" },
{ "type": "reduce_total_cost", "server_id": "quickstart", "tool_name": "invoke",
"max_total_cost": { "units": 50, "currency": "USD" } },
{ "type": "shorten_expiry", "new_expires_at": 1791779400 }
],
"timestamp": 1791778800
}
]chio lineage query --live --seed cap-01d2…6c13 --direction reversewalk reverse from cap-01d2…6c13, depth limit 20 depth 0 cap-01d2…6c13 capability tester depth 1 cap-01d2…2a90 capability fixer delegated to cap-01d2…6c13 root cap-01d2…2a90, issued by your kernel 4f1c…a2e9 2 nodes, 1 edge, walk complete
Each SDK option became one step. Dropping write removed the write tool, the fixer's depth of one removed delegate from every tool the child kept, fifty cents reduced the model route's cost ceiling, and ten minutes shortened the expiry. The kernel recomputes the child's scope from the parent and these steps, and admits the token only if the result matches.
Four checks pass, in order, before the child runs
The child keeps the parent’s /srv constraint, adds /srv/reports, and lowers the ceiling from ten invocations to three. The witness recomputes as a subset, the claimed parent matches the issuer’s trust root, the request matches the child’s scope and passes the guards, and only then does the registry commit the child’s share. What a child may inherit.
| Change | Typed step | Example |
|---|---|---|
| Remove a tool | RemoveTool | The tester loses write |
| Remove an operation | RemoveOperation | The tester loses delegate |
| Tighten a condition | AddConstraint | A narrower path, such as /workspace/tests |
| Reduce calls | ReduceBudget | At most 50 shell runs |
| Shorten lifetime | ShortenExpiry | Thirty minutes become ten |
| Lower spend | ReduceCostPerInvocation, ReduceTotalCost | USD 2.00 becomes USD 0.50 |
Call quotas and money are separate limits. A shared budget is held before dispatch, and a family cap bounds what siblings spend together.
6. Let an agent build its own tree
Your code delegated to the tester explicitly. An agent can also delegate on its own, within the depth and fan-out its grant sets. Give the triage agent the backlog, issues #417 to #419, with two levels and four children per agent.
const lead = await project.agent("triage");
const triageFork = await project.fork();
const triage = triageFork.grant({
to: lead,
resources: { "/workspace": ["read"], "/shell": ["run"], "/models/default": ["invoke"] },
delegation: { depth: 2, fanout: 4 },
budget: USD(2),
expiresIn: "20m",
});
const sweep = await lead.run("reproduce issues #417 to #419", { on: triageFork, authority: triage });
for await (const event of sweep.events()) console.log(event.summary);
await sweep.joinTree();00:00 admitted triage runs "reproduce issues #417 to #419" on fork 4e7b2d8 00:01 delegate triage to repro-417, repro-418, repro-419, each narrower 00:02 delegate repro-418 to bisect-a, bisect-b, depth 2 of 2 00:07 run pytest -k issue_417: failed, reproduced 00:09 refused bisect-a delegate: beyond delegation depth 2 00:31 joined triage and 5 delegates closed, USD 0.84 of 2.00
lead = project.agent("triage")
triage_fork = project.fork()
triage = triage_fork.grant(
to=lead,
resources={"/workspace": ["read"], "/shell": ["run"], "/models/default": ["invoke"]},
delegation=Delegation(depth=2, fanout=4),
budget=USD(2),
expires_in="20m",
)
sweep = lead.run("reproduce issues #417 to #419", on=triage_fork, authority=triage)
for event in sweep.events():
print(event.summary)
sweep.join_tree()00:00 admitted triage runs "reproduce issues #417 to #419" on fork 4e7b2d8 00:01 delegate triage to repro-417, repro-418, repro-419, each narrower 00:02 delegate repro-418 to bisect-a, bisect-b, depth 2 of 2 00:07 run pytest -k issue_417: failed, reproduced 00:09 refused bisect-a delegate: beyond delegation depth 2 00:31 joined triage and 5 delegates closed, USD 0.84 of 2.00
let lead = project.agent("triage").await?;
let triage_fork = project.fork().await?;
let triage = triage_fork.grant(
Grant::to(&lead)
.resource("/workspace", ["read"])
.resource("/shell", ["run"])
.resource("/models/default", ["invoke"])
.delegation(Delegation::depth(2).fanout(4))
.budget(USD(2))
.expires_in("20m"),
)?;
let task = lead.run("reproduce issues #417 to #419").on(&triage_fork).authority(triage);
let mut sweep = task.start().await?;
let mut events = sweep.events();
while let Some(event) = events.next().await {
println!("{}", event?.summary());
}
sweep.join_tree().await?;00:00 admitted triage runs "reproduce issues #417 to #419" on fork 4e7b2d8 00:01 delegate triage to repro-417, repro-418, repro-419, each narrower 00:02 delegate repro-418 to bisect-a, bisect-b, depth 2 of 2 00:07 run pytest -k issue_417: failed, reproduced 00:09 refused bisect-a delegate: beyond delegation depth 2 00:31 joined triage and 5 delegates closed, USD 0.84 of 2.00
You should see each delegate narrower than its parent and one refusal where a third level was attempted. Each hop carries its own child-scope witness, the signed task graph bounds depth and fan-out, and joinTree returns once every delegate has closed. Swarm authority covers the graph and its witnesses.
7. Revoke the parent, and the child stops
Start the tester again, then revoke the fixer's grant while it works. From the command line, chio trust revoke writes the revocation, and the tester's newest refusal shows where its chain broke.
const again = await tester.run("rerun test_refund_window", { on: fork, authority: child });
await authority.revoke();
for await (const event of again.events()) console.log(event.summary);00:00 admitted tester runs "rerun test_refund_window" on fork 9c3a5e1 00:01 revoked cap-01d2…2a90, by you 00:02 refused run pytest tests/test_pricing.py::test_refund_window: delegation chain revoked at cap-01d2…2a90 00:02 stopped tester closed, no effects after revocation
again = tester.run("rerun test_refund_window", on=fork, authority=child)
authority.revoke()
for event in again.events():
print(event.summary)00:00 admitted tester runs "rerun test_refund_window" on fork 9c3a5e1 00:01 revoked cap-01d2…2a90, by you 00:02 refused run pytest tests/test_pricing.py::test_refund_window: delegation chain revoked at cap-01d2…2a90 00:02 stopped tester closed, no effects after revocation
let task = tester.run("rerun test_refund_window").on(&fork).authority(child);
let mut again = task.start().await?;
authority.revoke().await?;
let mut events = again.events();
while let Some(event) = events.next().await {
println!("{}", event?.summary());
}00:00 admitted tester runs "rerun test_refund_window" on fork 9c3a5e1 00:01 revoked cap-01d2…2a90, by you 00:02 refused run pytest tests/test_pricing.py::test_refund_window: delegation chain revoked at cap-01d2…2a90 00:02 stopped tester closed, no effects after revocation
chio trust revoke --capability-id cap-01d2…2a90
chio receipt list --agent tester --outcome deny --order newest --limit 1 \
| jq -c '{id, tool: .tool_name, reason: .decision.reason}'revoked cap-01d2…2a90
{"id":"rcpt-01d2…f5c1","tool":"run","reason":"delegation chain revoked at ancestor cap-01d2…2a90"}The kernel checks the presented token and every ancestor in its delegation chain against its revocation state at admission. Revoking cap-01d2…2a90 ended the tester's token without naming it. A deployment with several kernels points them at one trust-control service, so the revocation reaches each kernel that admits the chain; Rotate Keys & Revoke covers that service and the revocation store.
Next: delegate across operators
A delegated grant carries no approval of its own: add one for a consequential operation with Human-in-the-Loop. Delegation to another operator's agents runs under a treaty, as Halden's did in Quickstart part 3; their kernel admits the work under its own policy.