Agent webs — multi-hop delegation
Build networks of agents — A delegates to B, which delegates to C and D, which may call MCP servers — where every hop carries signed, verifiable, shrinking authority, and a receipt tree returns to whoever started the chain.
A (root issuer) ──► B (orchestrator) ──► C (searcher)
└─► D (summariser) ──► MCP toolsA node in the web
serveDelegated() wraps serve() from @aroha-sdk/run. Requests without a valid mandate chain are rejected with 400 before your handler runs.
import { serveDelegated, registryResolver } from "@aroha-sdk/delegation";
serveDelegated("orchestrator", {
identity: { did: "did:aroha:acme:orchestrator", privateKey: myKey },
trustAnchors: { "did:aroha:human:alice": alicePublicKeyB64 },
resolvePublicKey: registryResolver(), // staticResolver({...}) for closed networks
}, async (ctx) => {
ctx.assertCapability("research"); // throws unless the verified mandate allows it
// Delegate onward — authority is attenuated automatically:
// subset scope, depth − 1, blocked list carried forward, same correlationId
const search = await ctx.delegate(searcherDidHash, ctx.message,
{ allowed: ["web-search"] });
const summary = await ctx.delegate(summariserDidHash, search.message,
{ allowed: ["summarise"] });
return summary.message;
}).start(8000);Starting a chain
The root issuer — a personal agent, a CLI, your backend — grants the first mandate and calls the first agent. maxDelegationDepth bounds how deep the web below it may grow.
import { issueDelegation, callDelegated } from "@aroha-sdk/delegation";
const { envelope } = await issueDelegation(
{ did: "did:aroha:human:alice", privateKey: aliceKey },
orchestratorDid,
{
allowed: ["research", "web-search", "summarise"],
constraints: { maxDelegationDepth: 1 }, // B may delegate once; C/D may not
ttlMs: 60_000,
},
);
const res = await callDelegated(orchestratorEndpoint, "history of agent protocols", envelope);
console.log(res.message);
console.log(res.receipts[0]); // B's receipt, with C's and D's nested in .childrenWhat the chain guarantees
Each hop appends one signed mandate to the envelope. Every node runs verifyMandateChain() over the whole path, root → leaf:
| Check | Attack it stops |
|---|---|
| Every link's Ed25519 signature | Forged or tampered mandates |
| Root key pinned to your trustAnchors | An attacker minting their own “root” authority |
| grantor(i) === grantee(i−1) + parentMandateId linkage | Splicing a mandate from another chain |
| allowed(i) ⊆ allowed(i−1) | Scope widening mid-chain |
| blocked list must survive every hop | Laundering a ban through a sub-agent |
| Child expiry ≤ parent expiry | Zombie authority outliving its grant |
| maxDelegationDepth strictly decrements | Runaway agent-spawns-agent recursion |
Depth is a hard stop. maxDelegationDepth: 0 makes a mandate a dead end — ctx.delegate() refuses with DELEGATION_DEPTH_EXCEEDED before a child token is even signed, and every downstream verifier independently re-checks the decrement.
Receipts come back as a tree
Every node builds a TaskReceipt automatically — actions, violations, timings — and ctx.delegate() nests downstream receipts, so the root issuer receives the whole execution tree under one correlationId:
orchestrator (complete) ├─ searcher (complete) — actions: web-search └─ summariser (complete) — actions: summarise
Run the full web locally
The SDK ships a complete A → B → (C, D) example — including a blocked over-delegation, a rejected forged root, and an out-of-grant refusal. Install @aroha-sdk/delegation and adapt it, or read the mandate model first: