Delegation Chains
In multi-agent systems, one agent often delegates work to another. Agent A asks Agent B to query a database, which asks Agent C to format the results. This creates a delegation chain — a sequence of agent-to-agent handoffs that MITRITY tracks, governs, and surfaces in the dashboard.
Without governance, delegation chains introduce serious security risks: privilege escalation (Agent A reaches resources through Agent B that it could not access directly), circular delegation (loops), and fan-out attacks (one agent triggering hundreds of sub-agents). MITRITY's delegation chain system addresses all of these.
Prerequisite: grant the MCP Delegate capability
Delegation is invoked via the delegate_to MCP tool that the MITRITY gateway exposes to your agents. MCP Delegate ships as a built-in in the Tools catalog (Built-in tab) with the single operation delegate_to and action pattern mcp:delegate:* — there is nothing to create. Before any agent can delegate, grant the MCP Delegate capability on each participating agent from its detail page → Capabilities → Add capability.
Without this grant, the agent's delegate_to calls fall through to policy-rule evaluation only — fine if your policy allows them, but you lose the capability-layer narrowing, and the privilege-escalation diff has no per-agent grants to compare against. If you hit "the chain never gets created" or "permissions column always shows -", a missing MCP Delegate grant is almost always the cause.
Evaluation architecture
Delegation hops are evaluated through a backend-mediated path. The control plane — not the edge node — is the source of truth on chain state.
| Hop | Where the decision is made | Why |
|---|---|---|
| Root hop (no upstream agent) | Edge node, against the local agent + capability/policy state. | There is no chain to consult yet; the edge already has every input it needs. |
| Continuation hop (any subsequent hop in a chain) | MITRITY backend, via POST /api/v1/delegation-chains/evaluate. | Cycle detection, depth, and privilege escalation all require the full chain — which the edge does not hold in trustworthy form. |
For continuation hops, the edge node sends the from_agent_id, the proposed to_agent_id, the chain_id, the action being requested, and the proposed delegate's capability set. The backend looks up the persisted chain from delegation_hops, walks the engine (depth, cycle, unauthorized-delegate, privilege-escalation), persists the new hop with its decision, and returns the verdict. The edge enforces what the backend returns.
This is a change from the older edge-only fast-path, which carried prior-hops in the request args and trusted the calling agent's code to be honest about chain state. Anything that worked under that model — including args.prior_hops — is now ignored. The backend rebuilds chain state from delegation_hops on every continuation hop, which means transitive cycles (where one hop alone looks fine but the chain as a whole loops) are caught.
The /evaluate request and response
curl -X POST https://api.mitrity.com/api/v1/delegation-chains/evaluate \
-H "Authorization: Bearer mk_your-api-key" \
-H "Content-Type: application/json" \
-d '{
"chain_id": "ch_8k2m4n",
"from_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"to_agent_id": "3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"action_requested": "db.postgres.insert",
"proposed_capabilities": [
{ "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
]
}'
{
"decision": "blocked",
"action_taken": "blocked",
"blocked_reason": "privilege_escalation",
"hop_index": 1,
"effective_permissions": {
"delegator_permissions": [
{ "tool_id": "postgres-read", "allowed_operations": ["select"] }
],
"delegate_permissions": [
{ "tool_id": "postgres-read", "allowed_operations": ["select"] },
{ "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
],
"escalated_resources": [
{ "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
]
}
}
action_taken vocabulary
action_taken on the evaluate response is past tense — it describes what already happened, not what should happen next. The four values are:
action_taken | Meaning |
|---|---|
allowed | The hop was allowed. The edge proceeds with the delegated call. |
blocked | The hop was blocked. The edge returns a denial to the calling agent. |
alerted | The hop was allowed, but flagged. An alert was raised and the action was logged for review. The edge proceeds. |
held | The hop is paused awaiting human approval. The edge waits for the approval workflow. |
Earlier internal drafts used present-tense verbs (allow / block); those are not the wire format. Always read action_taken as past tense.
Model
The system records two related entities:
- A chain — one per multi-agent workflow. Tracks the initiator, depth, status, and (if blocked) the reason.
- Zero or more hops — one per agent-to-agent handoff within the chain. Tracks the from-agent, to-agent, action requested, and the governance decision.
Every chain has a stable chain_id (string, generated by the edge). Hops within a chain are ordered by hop_index (zero-based).
Chain
{
"id": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"tenant_id": "9f8a7b6c-5d4e-3f2a-1b2c-4d5e6f7a8b9c",
"chain_id": "ch_8k2m4n",
"initiator_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"initiator_agent_name": "orchestrator",
"chain_depth": 3,
"status": "completed",
"blocked_at_hop": null,
"blocked_reason": null,
"started_at": "2026-05-22T10:00:00Z",
"completed_at": "2026-05-22T10:00:02Z",
"created_at": "2026-05-22T10:00:00Z"
}
Status values
| Value | Meaning |
|---|---|
active | Chain has at least one hop and is still receiving new hops. |
completed | Chain finished without any blocked hop. |
blocked | A hop was blocked; the chain is closed in this state. blocked_at_hop and blocked_reason are populated. |
timed_out | No new hop arrived within the timeout window after the last hop. Closed in this state. |
Hop
{
"id": "2d95038-4c54-7b2a-9d35-597a1e4f3b21",
"chain_id": "ch_8k2m4n",
"hop_index": 0,
"from_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"from_agent_name": "orchestrator",
"to_agent_id": "3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"to_agent_name": "data-fetcher",
"action_requested": "db.postgres.query",
"decision": "allowed",
"effective_permissions": ["read:public.analytics_*"],
"occurred_at": "2026-05-22T10:00:01Z"
}
decision is "allowed" or "blocked". from_agent_id is null on the first hop of root-level invocations (no upstream agent).
Blocked hops are persisted. When a continuation hop is denied by the engine — depth, cycle, fan-out, unauthorized-delegate, or privilege-escalation — the hop is still written to delegation_hops with decision: "blocked" and the chain's blocked_reason populated. This is what powers the dashboard's Blocked tab and lets you audit denied attempts after the fact.
Governance controls
Tenant-wide settings
The tenant-wide delegation policy applies to every agent unless overridden per agent. Configured at Security > Delegation Chains > Settings in the dashboard, or via the API:
curl -X PUT https://api.mitrity.com/api/v1/delegation-chains/settings \
-H "Authorization: Bearer mk_your-api-key" \
-H "Content-Type: application/json" \
-d '{
"max_chain_depth": 5,
"depth_exceeded_action": "deny",
"max_fan_out": 10,
"fan_out_window_seconds": 60,
"circular_action": "deny",
"privilege_escalation_action": "deny"
}'
| Field | Type | Default | Description |
|---|---|---|---|
max_chain_depth | integer (1–20) | 5 | Maximum number of hops in a delegation chain. |
depth_exceeded_action | enum | deny | Action when the next hop would exceed the limit. One of deny, alert, hold. |
max_fan_out | integer (1–100) | 10 | Maximum number of concurrent delegations from a single agent within fan_out_window_seconds. |
fan_out_window_seconds | integer (10–3600) | 60 | Rolling window for the fan-out count. |
circular_action | enum | deny | Action when a delegation would re-enter an agent already in the chain. |
privilege_escalation_action | enum | deny | Action when a hop would grant the delegate access the delegator lacks. |
Tenant settings are a Pro+ feature. Starter tenants run with the built-in defaults above.
Per-agent overrides
Override the tenant-wide limits for individual agents:
curl -X PATCH https://api.mitrity.com/api/v1/agents/1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e \
-H "Authorization: Bearer mk_your-api-key" \
-H "Content-Type: application/json" \
-d '{
"delegation_settings": {
"max_chain_depth": 8,
"allowed_delegates": ["agt-uuid-a", "agt-uuid-b"],
"disallowed_delegates": []
}
}'
allowed_delegates is an allow-list — when non-empty, the agent can only delegate to listed agent IDs. disallowed_delegates is a deny-list. The two are independent; both can be set. Per-agent values fall back to tenant settings when null.
Blocked reasons
When a hop is blocked, the chain's blocked_reason explains why:
| Reason | Description |
|---|---|
depth_exceeded | The chain would exceed max_chain_depth. |
circular_delegation | The delegation would re-enter an agent already in the chain. |
privilege_escalation | The delegate would gain access the delegator does not have. |
fan_out_exceeded | The delegating agent has exceeded max_fan_out within the rolling window. |
unauthorized_delegate | The delegate isn't in allowed_delegates (or is in disallowed_delegates) for the delegating agent. |
Privilege-escalation detail
The privilege-escalation check fires inside the backend /evaluate call. At each continuation hop, MITRITY computes the (tool, operation) pairs the proposed delegate holds but the calling agent does not. If that set is non-empty, the hop is flagged as privilege escalation, and the resolver populates delegation_hops.effective_permissions (JSONB) with the diff. The check is per-hop — delegator vs. proposed delegate. (Chain-intersection across every hop in the chain is a future enhancement.)
The action taken depends on the tenant's privilege_escalation_action setting:
| Setting | Behavior |
|---|---|
deny (default) | The hop is blocked with blocked_reason: "privilege_escalation". |
alert | The hop is allowed (action_taken: "alerted"); an alert event is raised; the escalated resources are still persisted on the hop for review. |
log | The hop is allowed (action_taken: "allowed"); the escalated resources are persisted but no alert is raised. |
The full diff payload looks like:
{
"hop_index": 1,
"from_agent_id": "read-only-bot-uuid",
"to_agent_id": "full-access-bot-uuid",
"action_requested": "db.postgres.insert",
"decision": "blocked",
"effective_permissions": {
"delegator_permissions": [
{ "tool_id": "postgres-read", "allowed_operations": ["select"] }
],
"delegate_permissions": [
{ "tool_id": "postgres-read", "allowed_operations": ["select"] },
{ "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
],
"escalated_resources": [
{ "tool_id": "postgres-write", "allowed_operations": ["insert", "update"] }
]
}
}
escalated_resources is the set difference — the (tool, operation) pairs the delegate holds that the calling agent does not. The dashboard's chain-detail page surfaces this diff in an Escalated resources card under the hops table whenever a hop's blocked_reason (or action_taken) carries the privilege_escalation signal. The card is rendered for both denied hops and alert/log-mode hops where the escalation was permitted but flagged.
Delegation policies
Beyond the tenant + per-agent settings above, you can write policies that target delegation specifically via the delegate.* action pattern and the constraints.delegation block:
{
"name": "hold-cross-env-delegation",
"action_pattern": "delegate.*",
"action": "hold",
"hold_timeout_minutes": 15,
"timeout_action": "deny",
"constraints": {
"delegation": {
"cross_environment": true
}
}
}
Policy constraints.delegation fields:
| Field | Type | Description |
|---|---|---|
disallowed_delegates | array of agent IDs | Block delegations to any of these agents (matches if to_agent_id is in the list). |
allowed_delegates | array of agent IDs | Allow delegations only to these agents (matches if to_agent_id is NOT in the list). |
cross_environment | bool | Match when delegator and delegate belong to different environments. |
max_depth_override | integer | Override the tenant max_chain_depth for the matching policy scope. |
Policy hits override tenant defaults and per-agent overrides — policies are the most specific control surface.
API Reference
All endpoints mounted at /api/v1/delegation-chains. Bearer-token auth, tenant-scoped.
List chains
curl "https://api.mitrity.com/api/v1/delegation-chains?status=blocked&blocked_reason=privilege_escalation&limit=50" \
-H "Authorization: Bearer mk_your-api-key"
Query parameters — all optional, composable, empty values treated as absent.
| Parameter | Type | Description |
|---|---|---|
agent_id | UUID | Chains where this agent is the initiator or appears as from/to in any hop. |
status | enum | active, completed, blocked, timed_out. |
blocked_reason | enum | Filter blocked chains by reason. |
min_depth | integer | Chains with chain_depth >= N. |
started_after | RFC3339 | Chains started after this timestamp. |
started_before | RFC3339 | Chains started before this timestamp. |
limit | integer (1–100) | Default 50. |
offset | integer | Default 0. |
Returns an array of chain objects. Sorted by started_at desc.
Get a chain
curl https://api.mitrity.com/api/v1/delegation-chains/ch_8k2m4n \
-H "Authorization: Bearer mk_your-api-key"
Returns the chain plus its ordered list of hops:
{
"id": "...",
"chain_id": "ch_8k2m4n",
"initiator_agent_id": "...",
"...": "...",
"hops": [
{ "hop_index": 0, "from_agent_id": null, "to_agent_id": "...", "decision": "allowed", "..." : "..." },
{ "hop_index": 1, "from_agent_id": "...", "to_agent_id": "...", "decision": "allowed", "..." : "..." }
]
}
Summary
curl https://api.mitrity.com/api/v1/delegation-chains/summary \
-H "Authorization: Bearer mk_your-api-key"
Aggregate counts for the calling tenant — lifetime totals.
{
"total_chains": 4521,
"total_hops": 12847,
"avg_depth": 2.8,
"max_depth_observed": 7,
"by_status": {
"active": 12,
"completed": 4475,
"blocked": 30,
"timed_out": 4
},
"by_blocked_reason": {
"circular_delegation": 3,
"depth_exceeded": 8,
"privilege_escalation": 15,
"fan_out_exceeded": 3,
"unauthorized_delegate": 1
},
"top_initiators": [
{ "agent_id": "...", "agent_name": "orchestrator", "chain_count": 2340 }
],
"top_delegates": [
{ "agent_id": "...", "agent_name": "data-fetcher", "delegation_count": 3450 }
]
}
Settings
GET /api/v1/delegation-chains/settings
PUT /api/v1/delegation-chains/settings
See Tenant-wide settings for the request body.
Evaluate a continuation hop
POST /api/v1/delegation-chains/evaluate
Called by edge nodes on every continuation hop. The backend resolves the full chain from delegation_hops, runs the engine, persists the new hop, and returns the decision. Most customers will never call this endpoint directly — it's exercised on your behalf by your edge nodes — but it's documented here for completeness and for customers building custom integrations. See Evaluation architecture above for the request and response shape.
Dashboard
Navigate to Security > Delegation Chains. Four tabs:
- Chains — paginated list with the same filters as the API.
- Blocked — pre-filtered to
status=blocked; populates with every denied hop the engine catches (depth, cycle, fan-out, unauthorized-delegate, privilege-escalation). Quick triage for hot spots and the first place to look when a customer reports "the agent stopped delegating." - Topology — DAG of all delegation pairs within a rolling window (7 / 30 / 90 days), node = agent, edge thickness = delegation count, red = blocked attempts.
- Settings — the tenant-wide controls described above (Pro+ only).
The chain detail view shows the full hop sequence and the chain visualization (linear for chains under depth 6, DAG for deeper). When a hop carries the privilege_escalation signal — whether it was blocked or merely alerted — the detail page renders an Escalated resources card showing the (tool, operation) diff between the calling agent and the proposed delegate.
Best practices
Start with the defaults
The tenant defaults — max_chain_depth: 5, max_fan_out: 10, all *_action set to deny — are a safe baseline for most deployments. Loosen knobs only with evidence (a specific workflow needs depth 8, a specific agent legitimately fans out to 20 sub-agents).
Use allowed_delegates for critical agents
For agents that touch sensitive resources (billing, customer data, infrastructure), explicitly enumerate which agents they may delegate to. Drift in production agent topologies is real; an allow-list catches new delegation paths before they go live.
Treat privilege escalation as deny
privilege_escalation_action: alert lets the escalation happen and just logs it. In production, deny is the right choice unless you have a specific reason — the whole point of intersection semantics is that the delegator's reach is the ceiling.
Review blocked chains weekly
Blocked delegations are signal. Spend 15 minutes a week in the Blocked tab. Recurring patterns point at either an over-tight policy (loosen with intent) or a misbehaving agent (the actual goal of the system).
Related documentation
- Injection Detection — detect prompt injection in delegated instructions
- Credential Broker — manage credential access across delegation chains
- Threat Intelligence — behavior-based threat indicators on agent actions
- RBAC — role-based access control for team members