New: Agent approvals, red teaming and model scanning. See how
MITRITY
Docs / Security / Delegation Chains

Delegation Chains

Delegation chain governance controls how your agents hand work to each other. In multi-agent systems, one agent often delegates work to another: Agent A asks Agent B to query a database, and Agent B asks Agent C to format the results. This creates a delegation chain, a sequence of agent-to-agent handoffs that MITRITY tracks, governs and shows 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, where a delegation re-enters an agent already in the chain;
  • fan-out attacks: one agent triggers hundreds of sub-agents.

MITRITY's delegation chain system addresses all of these.

Prerequisite: grant the MCP Delegate capability

Through a gateway, an agent delegates by calling a delegate_to tool that an upstream of your own serves under the namespace delegate, so the call is mcp:delegate__delegate_to. The gateway does not serve this tool itself; the multi-agent demo ships such an upstream. MCP Delegate is the catalog's definition for that tool, not the tool: it ships as a built-in in the Tools catalog (Built-in tab), with the single operation delegate_to and the action pattern mcp:delegate__*, so there is nothing to create. It matches only that namespace: an agent-to-agent hop on the mesh is delegate:delegate_to, which it does not match.

Grant the MCP Delegate capability on each participating agent, so that the capability layer covers its delegation calls: on the agent's detail page, open Capabilities → Add capability.

Without this grant, the agent's delegate_to calls fall through to policy-rule evaluation only. That is fine if your policy allows them, but you lose the capability-layer narrowing. The hop is recorded either way, because the edge evaluates the delegation before it checks Capabilities, and the privilege-escalation diff compares all of the two agents' Capabilities, not only MCP Delegate.

Evaluation architecture

Delegation hops are evaluated through a path that the backend mediates. The control plane, not the edge node, is the source of truth on chain state.

The edge sends every hop to the MITRITY backend, the first hop of a chain included, through POST /api/v1/delegation-chains/evaluate. Cycle detection, depth and privilege escalation all need the full chain, which the edge does not hold in trustworthy form. A call is a hop when:

  • through a gateway, its arguments carry delegation_chain_id, and to_agent_id names the delegate;
  • on the mesh, an agent-to-agent call carries the x-mitrity-delegation-chain-id header (see Mesh Identity).

For each hop, the edge sends the chain_id, the calling agent's ID, name and mission scope, the action type it evaluated, and the proposed delegate's agent ID and worker name. The backend then:

  1. looks up the persisted chain from delegation_hops;
  2. walks the engine (depth, cycle, unauthorized-delegate, privilege-escalation), comparing the two agents' enabled Capabilities as the control plane stores them;
  3. persists the new hop with its decision;
  4. returns the verdict.

If the endpoint cannot be reached, the policy's fail_mode decides: closed denies the hop, and open evaluates it on the edge against that single hop, and marks the audit event with chain_state_fallback.

The edge enforces what the backend returns.

Chain state never comes from the calling agent, because an agent's own account of its chain is not evidence. Prior hops carried in the request (args.prior_hops) are ignored. The backend rebuilds chain state from delegation_hops on every hop, so it catches transitive cycles, where one hop alone looks fine but the chain as a whole loops.

The /evaluate request and response

curl -X POST https://api.mitrity.com/api/v1/delegation-chains/evaluate \
  -H "X-Agent-Key: ak_your-agent-key" \
  -H "Content-Type: application/json" \
  -d '{
    "chain_id": "ch_8k2m4n",
    "current_agent_id": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
    "current_agent_name": "orchestrator",
    "current_mission_scope": "Coordinate customer-order workflow",
    "current_action": "mcp:delegate__delegate_to",
    "proposed_target_agent_id": "3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
    "proposed_target_worker": "data-worker"
  }'

current_agent_id must be the agent the key authenticates, or the call gets 403 Forbidden. On the first hop of a chain, the response looks like this:

{
  "allowed": false,
  "reason": "privilege_escalation",
  "chain_depth": 1,
  "agents_in_chain": ["3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c"],
  "action_taken": "blocked",
  "audit_event_id": ""
}

reason is empty when the hop is allowed. audit_event_id is always empty: the endpoint writes no audit event of its own, and the edge's event for the delegation call is the audit record. The privilege-escalation diff is not in the response: it is stored on the hop as effective_permissions, which the chain detail shows as Escalated resources.

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_takenMeaning
allowedThe hop was allowed. The edge proceeds with the delegated call.
blockedThe hop was blocked. The edge returns a denial to the calling agent.
alertedThe hop was allowed, but flagged. An alert was raised and the action was logged for review. The edge proceeds.
heldThe hop is paused awaiting human approval. The edge waits for the approval workflow.

Present-tense verbs (allow / block) 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. It tracks the initiator, depth, status and, if blocked, the reason.
  • Zero or more hops: one per agent-to-agent handoff within the chain. A hop tracks the from-agent, the to-agent, the action requested and the governance decision.

Every chain has a stable chain_id, a string that the delegating agent supplies: the delegation_chain_id argument through a gateway, or the x-mitrity-delegation-chain-id header on the mesh. 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:

ValueMeaning
activeChain has at least one hop and is still receiving new hops.
completedChain finished without any blocked hop.
blockedA hop was blocked; the chain is closed in this state. blocked_at_hop and blocked_reason are populated.
timed_outNo 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": "mcp:delegate__delegate_to",
  "decision": "allowed",
  "effective_permissions": null,
  "occurred_at": "2026-05-22T10:00:01Z"
}

decision is "allowed" or "blocked". action_requested is the action type of the delegation call. effective_permissions is null, except on a hop blocked for privilege escalation, where it holds the diff: delegator_permissions, delegate_permissions and escalated_resources. from_agent_id is the calling agent, and is null only on a chain's first hop when the calling agent names itself as the delegate.

Blocked hops are persisted. When the engine denies a hop (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 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 you override it per agent. Configure it at Security > Delegation Chains > Settings in the dashboard, or through 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"
  }'
FieldTypeDefaultDescription
max_chain_depthinteger (1–20)5Maximum number of hops in a delegation chain.
depth_exceeded_actionenumdenyAction when the next hop would exceed the limit. One of deny, alert, hold.
max_fan_outinteger (1–100)10Maximum number of concurrent delegations from a single agent within fan_out_window_seconds.
fan_out_window_secondsinteger (10–3600)60Rolling window for the fan-out count.
circular_actionenumdenyAction when a delegation would re-enter an agent already in the chain.
privilege_escalation_actionenumdenyAction 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

To override the tenant-wide limits for an individual agent:

curl -X PUT 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 it is non-empty, the agent can delegate only to the listed agent IDs.
  • disallowed_delegates is a deny-list.

The two lists are independent, and you can set both. Per-agent values fall back to the tenant settings when null.

Blocked reasons

When a hop is blocked, the chain's blocked_reason explains why:

ReasonDescription
depth_exceededThe chain would exceed max_chain_depth.
circular_delegationThe delegation would re-enter an agent already in the chain.
privilege_escalationThe delegate would gain access the delegator does not have.
fan_out_exceededThe delegating agent has exceeded max_fan_out within the rolling window.
unauthorized_delegateThe 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 hop, MITRITY computes the (tool, operation) pairs that the proposed delegate holds and 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: it compares the delegator with the proposed delegate. It does not intersect permissions across every hop of the chain.

The action taken depends on the tenant's privilege_escalation_action setting:

SettingBehavior
deny (default)The hop is blocked with blocked_reason: "privilege_escalation".
alertThe hop is allowed (action_taken: "alerted"); an alert event is raised; the escalated resources are still persisted on the hop for review.
logThe 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": "1b2c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
  "to_agent_id": "3f2a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
  "action_requested": "mcp:delegate__delegate_to",
  "decision": "blocked",
  "effective_permissions": {
    "delegator_permissions": [
      { "tool_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b", "tool_name": "MCP Delegate", "allowed_operations": ["delegate_to"] }
    ],
    "delegate_permissions": [
      { "tool_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b", "tool_name": "MCP Delegate", "allowed_operations": ["delegate_to"] },
      { "tool_id": "8b9c0d1e-2f3a-4b5c-8d6e-7f8a9b0c1d2e", "tool_name": "MCP Database", "allowed_operations": ["create_order", "fetch_orders", "query_database"] }
    ],
    "escalated_resources": [
      { "tool_id": "8b9c0d1e-2f3a-4b5c-8d6e-7f8a9b0c1d2e", "tool_name": "MCP Database", "allowed_operations": ["create_order", "fetch_orders", "query_database"] }
    ]
  }
}

escalated_resources is the set difference: the (tool, operation) pairs that the delegate holds and the calling agent does not. The dashboard's chain-detail page shows 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 appears both for denied hops and for alert/log-mode hops, where the escalation was permitted but flagged.

Delegation policies

Beyond the tenant and per-agent settings above, you can write policies that target delegation by action pattern. Through a gateway that serves the delegation upstream under the namespace delegate, the action type is mcp:delegate__delegate_to: the namespace joined to the tool name by __. A rule with tool_pattern: mcp:delegate__* therefore matches every delegation hop through that upstream:

{
  "name": "hold-delegation",
  "tool_pattern": "mcp:delegate__*",
  "action": "deny",
  "hold": true,
  "priority": 400,
  "hold_timeout_minutes": 15,
  "timeout_action": "deny"
}

Action patterns are globs, so . is a literal character, and * does not treat : specially. delegate.* matches nothing, because the separator is a colon. In the mesh, the Authorizer emits delegate:delegate_to without the mcp: prefix, so a mesh-scoped rule needs delegate:*.

There is no constraints.delegation block. Rule constraints match the arguments of an action, and the edge silently drops any constraint whose value is a JSON object: see Writing Policies. A policy carrying a delegation object is accepted by the API and then ignored, so the rule behaves as if the constraint were not there. cross_environment and max_depth_override do not exist in any form.

Which delegates an agent may reach, and how deep a chain may run, are the per-agent settings above (allowed_delegates, disallowed_delegates, max_chain_depth under delegation_settings). The delegation engine evaluates them on every hop. Use policies for the decision shape: hold every delegation for approval, alert on it, or deny it outright. Use per-agent settings for who may delegate to whom.

API Reference

All endpoints are mounted at /api/v1/delegation-chains, with Bearer-token auth, and 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 are all optional and composable. Empty values are treated as absent.

ParameterTypeDescription
agent_idUUIDChains where this agent is the initiator or appears as from/to in any hop.
statusenumactive, completed, blocked, timed_out.
blocked_reasonenumFilter blocked chains by reason.
min_depthintegerChains with chain_depth >= N.
started_afterRFC3339Chains started after this timestamp.
started_beforeRFC3339Chains started before this timestamp.
limitinteger (1–100)Default 50.
offsetintegerDefault 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, as 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 hop

POST /api/v1/delegation-chains/evaluate

Edge nodes call this endpoint on every hop, the first one of a chain included. It is authenticated with the calling agent's key in the X-Agent-Key header, not with an API key. The backend resolves the full chain from delegation_hops, runs the engine, persists the new hop and returns the decision.

Most customers never call this endpoint directly, because their edge nodes call it on their behalf. It is documented here for completeness, and for customers who build custom integrations. See Evaluation architecture above for the request and response shape.

Dashboard

Go to Security > Delegation Chains. The page has four tabs:

  • Chains: a paginated list, with the same filters as the API.
  • Blocked: pre-filtered to status=blocked. It fills with every denied hop the engine catches (depth, cycle, fan-out, unauthorized-delegate, privilege-escalation). Use it for quick triage of hot spots. It is the first place to look when a customer reports "the agent stopped delegating."
  • Topology: a 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, a DAG for deeper ones. When a hop carries the privilege_escalation signal, whether it was blocked or only alerted, the detail page shows an Escalated resources card with 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, every *_action set to deny) are a safe baseline for most deployments. Loosen a knob only with evidence, for example a specific workflow that needs depth 8, or a specific agent that legitimately fans out to 20 sub-agents.

Use allowed_delegates for critical agents

For agents that touch sensitive resources (billing, customer data, infrastructure), list explicitly which agents they may delegate to. Production agent topologies do drift, and 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 only logs it. In production, deny is the right choice unless you have a specific reason: with intersection semantics, 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 either at an over-tight policy, which you loosen with intent, or at a misbehaving agent, which is what the system is there to catch.