Chio/Docs
LOGIN · JOIN

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.

Guide chio@5f63e6e

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.

Start

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.

delegate.tsdelegate.pysrc/main.rs · in main
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());
Output
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, tester

The 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.

delegate.tsdelegate.pysrc/main.rs · in main
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);
Output
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.

delegate.tsdelegate.pysrc/main.rs · in mainTerminal
const [refusal] = await project.history.receipts({ outcome: "deny", agent: "tester" });
console.log(refusal.describe());
Output
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

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.

delegate.tsdelegate.pysrc/main.rs · in main
try {
  authority.delegate({ to: tester, resources: { "/secrets": ["read"] } });
} catch (error) {
  if (error instanceof AttenuationError) console.log(error.message);
}
Output
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.

delegate.tsdelegate.pysrc/main.rs · in mainTerminal
console.log(JSON.stringify(child.token.delegation_chain, null, 2));
Output
[
  {
    "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
  }
]

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.

Delegated authorityChoose a case.

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.

One delegated capability and the four checks it meets, in the kernel’s order. Invocation counts, paths, and basis points are illustrative. Revocation, lifetime, and proof of possession are checked on the same path and are left out.Delegation and attenuation
ChangeTyped stepExample
Remove a toolRemoveToolThe tester loses write
Remove an operationRemoveOperationThe tester loses delegate
Tighten a conditionAddConstraintA narrower path, such as /workspace/tests
Reduce callsReduceBudgetAt most 50 shell runs
Shorten lifetimeShortenExpiryThirty minutes become ten
Lower spendReduceCostPerInvocation, ReduceTotalCostUSD 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.

delegate.tsdelegate.pysrc/main.rs · in main
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();
Output
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.

delegate.tsdelegate.pysrc/main.rs · in mainTerminal
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);
Output
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

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.