Skip to content
Agentic Control Plane

Agent-to-Agent Governance

A user asks an AI assistant to onboard a new hire. The assistant delegates to a provisioning agent, which creates accounts in GitHub and Jira. The provisioning agent delegates to a notification agent, which sends a welcome message in Slack.

Three agents. Four systems. One human at the root. Every action needs to be authorized, attributed, and auditable — but who’s checking?

In most agent frameworks, nobody is. The delegation happens through shared credentials. The provisioning agent has the same access as the original assistant. The notification agent can read things it was never supposed to see. And the audit trail — if there is one — says “the AI did it” with no attribution to the human who initiated the chain.

This is the agent-to-agent governance problem. And it’s harder than the identity problem, because the trust boundary isn’t a single hop — it’s a chain. Permissions need to narrow, not widen. Budgets need to propagate. Cycles need to be prevented. And the full chain needs to be recorded end-to-end.

ACP solves this with delegation chains — a data structure that carries identity, permissions, tools, and budget through every agent hop, narrowing at each boundary.


How Delegation Chains Work

A delegation chain is a linked list of agent hops. Each link records which agent is acting, what permissions it has, what tools it can use, and what budget remains. The chain starts with a human and extends with each delegation.

Here’s the data structure:

interface DelegationChain {
  /** The human at the root — never changes. */
  originSub: string;
  originClaims: any;
  links: DelegationLink[];  // [0] = first agent, [last] = current
  depth: number;
}

interface DelegationLink {
  agentProfileId: string;
  agentRunId: string;
  agentName: string;
  effectiveScopes: string[];     // narrowed via intersection
  effectiveTools: string[];      // narrowed via intersection
  remainingBudgetCents: number;
  delegatedAt: string;           // ISO timestamp
}

Two invariants are always true:

  1. originSub never changes. No matter how deep the chain goes, the human at the root is always recorded. You can always trace an action back to the person who started it.
  2. Permissions only narrow. Each link has effective scopes and tools that are the intersection of its own permissions and its parent’s. A child agent can never have more access than its parent.
Alice JWT verified scopes: github.*, slack.*, jira.* delegates Planning Agent scopes: github.*, jira.* budget: $5.00 delegates Provisioning Agent scopes: github.repos.create budget: $2.00 (narrowed) calls GitHub API Identity verified x-user-uid: Alice Audit trail: Alice → Planning Agent → Provisioning Agent → github.repos.create | scopes narrowed at each hop | budget: $2.00 remaining Permissions can only narrow through a delegation chain — never widen

Notice what happened: Alice has access to GitHub, Slack, and Jira. The Planning Agent only gets GitHub and Jira (no Slack). The Provisioning Agent only gets github.repos.create (not all of GitHub). At each hop, permissions narrowed. And the audit trail records the full chain with Alice at the root.


Scope Narrowing: The Intersection Algorithm

When an agent delegates to another agent, the child’s effective permissions are computed as the intersection of the parent’s effective permissions and the child’s profile permissions. This is the core security invariant.

Here’s the actual implementation:

/**
 * Intersect two scope arrays. Returns only scopes present in both.
 * Supports wildcard prefixes: "github.*" matches "github.repos.list".
 */
export function intersectScopes(
  parentScopes: string[],
  childProfileScopes: string[]
): string[] {
  if (parentScopes.length === 0 || childProfileScopes.length === 0) return [];

  const result: string[] = [];
  for (const childScope of childProfileScopes) {
    if (matchesAny(childScope, parentScopes)) {
      result.push(childScope);
    }
  }
  return result;
}

function matchesAny(value: string, patterns: string[]): boolean {
  for (const pattern of patterns) {
    if (pattern === value) return true;
    if (pattern.endsWith(".*")) {
      const prefix = pattern.slice(0, -1); // "github."
      if (value.startsWith(prefix)) return true;
    }
  }
  return false;
}

The wildcard support is important. If a parent agent has github.* and the child profile requests github.repos.create, the child gets github.repos.create. But if the parent only has github.repos.read, the child can’t get write access — even if its profile says it should have it. Permissions only narrow.

The same intersection applies to tools:

export function intersectTools(
  parentTools: string[],
  childProfileTools: string[]
): string[] {
  // Empty parent means unrestricted — child gets their full set
  if (parentTools.length === 0) return [...childProfileTools];
  // Empty child means nothing requested
  if (childProfileTools.length === 0) return [];

  const result: string[] = [];
  for (const childTool of childProfileTools) {
    if (matchesAny(childTool, parentTools)) {
      result.push(childTool);
    }
  }
  return result;
}

One subtle detail: an empty parent tool list means “unrestricted” (the root agent has access to all tools in the workspace). But an empty child tool list means “no tools” — the child asked for nothing, so it gets nothing. This prevents accidental escalation if a profile is misconfigured.


The Full Delegation Flow

When Agent A invokes Agent B, here’s exactly what happens:

1. Validation checks

Before the child agent runs, the system validates a series of conditions:

✓ Plan check      — Agent delegation requires Pro or Enterprise plan
✓ Chain required   — Can only delegate from within an existing agent run
✓ Profile exists   — Target agent profile must exist in the workspace
✓ Delegatable flag — Target profile must have delegatable: true
✓ Cycle detection  — Target profile must not already be in the chain
✓ Depth limit      — Chain depth must not exceed plan limit (default: 5)
✓ Budget check     — Parent must have remaining budget > 0
✓ Model valid      — Target's model must be enabled in workspace config
✓ API key          — Workspace or platform must have key for the model's provider

2. Chain building

If all checks pass, the system builds the child chain:

export function buildChildChain(
  parentChain: DelegationChain,
  targetProfile: {
    id: string;
    name: string;
    scopes: string[];
    enabledTools: string[];
    maxBudgetCents: number;
  },
  childRunId: string,
): DelegationChain {
  const parentLink = parentChain.links[parentChain.links.length - 1];

  const effectiveScopes = intersectScopes(
    parentLink.effectiveScopes, targetProfile.scopes);
  const effectiveTools = intersectTools(
    parentLink.effectiveTools, targetProfile.enabledTools);
  const remainingBudgetCents = Math.min(
    parentLink.remainingBudgetCents, targetProfile.maxBudgetCents);

  return {
    originSub: parentChain.originSub,      // Alice — never changes
    originClaims: parentChain.originClaims, // preserved
    links: [...parentChain.links, {
      agentProfileId: targetProfile.id,
      agentRunId: childRunId,
      agentName: targetProfile.name,
      effectiveScopes,
      effectiveTools,
      remainingBudgetCents,
      delegatedAt: new Date().toISOString(),
    }],
    depth: parentChain.depth + 1,
  };
}

Three things narrow at each hop:

  • Scopes — intersection of parent’s effective scopes and child’s profile scopes
  • Tools — intersection of parent’s effective tools and child’s profile tools
  • Budget — minimum of parent’s remaining budget and child’s max budget

3. Child execution

The child agent runs with the narrowed chain. Every tool call the child makes passes through the full governance pipeline — and the delegation enforcement layer checks the child’s effectiveTools and effectiveScopes from the chain:

// In governToolCall():
if (delegationChain && delegationChain.links.length > 0) {
  const currentLink = delegationChain.links[delegationChain.links.length - 1];

  // Verify tool is in current link's effective tools
  const toolAllowed = currentLink.effectiveTools.some((pattern) => {
    if (pattern === toolName) return true;
    if (pattern.endsWith(".*") && toolName.startsWith(pattern.slice(0, -1)))
      return true;
    return false;
  });

  if (!toolAllowed) {
    return {
      allowed: false,
      error: "Tool not permitted in delegation chain",
      code: -32004,
      data: { tool: toolName, effectiveTools: currentLink.effectiveTools },
    };
  }

  // Override scopes for downstream checks
  auth.scopes = currentLink.effectiveScopes;
}

This is enforced server-side on every tool call. The child agent can’t bypass it by constructing tool calls differently — the governance pipeline sees the delegation chain and enforces the narrowed permissions regardless of what the agent’s LLM tries to do.

4. Result propagation

When the child finishes, its result flows back to the parent agent. The parent’s run document is updated with the child run ID, and the child’s execution is fully recorded:

{
  output: "Created GitHub repo 'acme/new-hire-onboarding'...",
  childRunId: "run_abc123",
  conversationId: "conv_def456",
  usage: {
    promptTokens: 1240,
    completionTokens: 380,
    toolCallCount: 3,
  },
  delegation: {
    originSub: "auth0|alice",
    depth: 2,
    chain: ["planning-agent", "provisioning-agent"],
    runChain: ["run_parent", "run_abc123"],
  }
}

Safety Mechanisms

Cycle Detection

A delegation chain must never loop. If Agent A delegates to Agent B, which delegates back to Agent A, you get an infinite loop that burns budget and produces no useful work. The system prevents this:

export function detectCycle(chain: DelegationChain, targetProfileId: string): boolean {
  return chain.links.some((link) => link.agentProfileId === targetProfileId);
}

If the target agent’s profile ID appears anywhere in the existing chain, the delegation is rejected with CYCLE error code.

Depth Limits

Even without cycles, unbounded delegation depth is dangerous. A planning agent that delegates to a research agent that delegates to a data agent that delegates to an analysis agent — the chain can get deep fast. Each hop multiplies latency and cost.

Depth limits are configurable per plan:

  • Free plan: Agent delegation disabled entirely
  • Pro plan: Maximum 3 levels deep
  • Enterprise: Up to 10 levels, configurable per workspace

Budget Propagation

Budget flows downward through the chain and can only shrink. If the parent agent has $5.00 remaining and the child profile has a max budget of $2.00, the child gets $2.00. If the parent only has $1.50 remaining, the child gets $1.50 — regardless of its configured maximum.

export function computeChildBudget(
  parentRemainingBudgetCents: number,
  childProfileMaxBudgetCents: number,
): number {
  return Math.min(parentRemainingBudgetCents, childProfileMaxBudgetCents);
}

When the child’s budget hits zero, its tool calls are rejected. The parent agent receives the result (or error) and can decide what to do — retry with a different approach, report failure, or continue with partial results.

Budget isn’t just a number — it’s tracked against real model pricing. ACP maintains a per-model pricing table covering OpenAI, Anthropic, Google, and Groq models, and calculates actual cost in cents after every LLM call:

export function calculateCostCents(
  model: string,
  promptTokens: number,
  completionTokens: number,
): number {
  const pricing = MODEL_PRICING[model] ?? DEFAULT_PRICING;
  const inputCost = (promptTokens / 1_000_000) * pricing.inputPer1M;
  const outputCost = (completionTokens / 1_000_000) * pricing.outputPer1M;
  return Math.round((inputCost + outputCost) * 100 * 100) / 100;
}

When an agent delegates to a child, the child’s cumulative cost is tracked in the completion loop. If the child hits its budget ceiling, the next tool call is rejected with BUDGET error — the child stops, returns what it has, and the parent continues with its remaining budget minus what the child consumed.

Delegation Permissions

Not every agent can delegate. Two flags on the agent profile control delegation behavior:

  • delegatable: boolean — Can this agent be invoked by other agents? Setting this to false prevents any agent from calling this profile via agents.invoke. It also hides the agent from agents.list and from the A2A agent card.
  • canDelegate: boolean — Can this agent invoke other agents? Setting this to false means the agent can run tools but can’t start sub-agent chains. The agents.invoke and agents.list tools are stripped from its available tools entirely.

This lets you build a clear delegation hierarchy: orchestration agents that can delegate but aren’t directly invocable, worker agents that can be invoked but can’t delegate further, and hybrid agents that can do both.


Immutable Platform Rules

Before any tenant-configurable governance runs — before delegation enforcement, scope checks, ABAC policies, rate limits, or content scanning — the system evaluates a set of immutable platform rules that cannot be overridden by any tenant configuration.

These are hardcoded, bypass-immune protections:

export function checkImmutableRules(
  toolName: string,
  input: any,
): ImmutableRuleResult {
  const inputStr = typeof input === "string"
    ? input : JSON.stringify(input ?? {});

  // Rule 1: Always block SSNs in input
  if (SSN_PATTERN.test(inputStr)) {
    return {
      allowed: false,
      rule: "ssn_block",
      reason: "Input contains a Social Security Number pattern — blocked by platform policy",
    };
  }

  // Rule 2: Always block credit card numbers in input
  if (CREDIT_CARD_PATTERN.test(inputStr)) {
    return {
      allowed: false,
      rule: "credit_card_block",
      reason: "Input contains a credit card number pattern — blocked by platform policy",
    };
  }

  // Rule 3: SSRF protection — block requests to private/internal networks
  const urlFields = extractUrls(input);
  for (const url of urlFields) {
    for (const pattern of PRIVATE_IP_PATTERNS) {
      if (pattern.test(url)) {
        return {
          allowed: false,
          rule: "ssrf_block",
          reason: "Request targets a private/internal network address — blocked by platform policy",
        };
      }
    }
  }

  return { allowed: true };
}

Why does this matter for agent-to-agent? Because delegation creates a larger attack surface. An agent three levels deep in a delegation chain could be manipulated by prompt injection to include an SSN in a tool call, or to target an internal IP. The immutable rules are the safety net — they fire on every tool call at every depth, regardless of how permissive the tenant’s content policy is.

The SSRF protection is particularly important for delegation chains. It blocks requests to localhost, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local), and metadata.google.internal (GCP metadata service). A delegated agent can’t be tricked into hitting your internal network.


The Full Governance Stack

Every tool call in a delegation chain passes through seven governance layers. Here’s the full pipeline as it appears in the governToolCall function:

Layer 0a: Immutable platform rules (SSN, credit card, SSRF — bypass-immune)
Layer 0b: Delegation chain enforcement (scope/tool intersection)
Layer 1:  Scope enforcement (OAuth scopes vs tool requirements)
Layer 2:  ABAC policy rules (attribute-based access control)
Layer 3:  Rate limit + budget preflight (per-user, per-tenant)
Layer 4:  Plan limit check (subscription feature gates)
Layer 5:  Content scanning + PII redaction (transformabl-core)

Layers 0a and 0b are the ones that make delegation safe. Layer 0a catches dangerous input regardless of configuration. Layer 0b narrows the auth scopes to the delegation chain’s effective scopes — so every downstream layer enforces the narrowed permissions, not the original user’s full access.

The key line in the governance code:

// Override auth scopes to the chain's effective scopes for downstream checks
auth.scopes = currentLink.effectiveScopes;

This single line is what makes the entire governance stack delegation-aware. After it runs, scope enforcement (layer 1) checks the child’s effective scopes, not the parent’s. ABAC policies (layer 2) evaluate against the narrowed identity. Rate limits (layer 3) still key off the original user — because the human at the root is accountable for all downstream usage.


Hooks: Approve, Deny, or Modify Tool Calls

ACP supports tenant-configurable webhooks that fire before or after tool execution. This is how you extend governance beyond what’s built in — connecting to your compliance system, approval workflow, or monitoring pipeline.

Pre-hooks

Pre-hooks fire before a tool executes. Your webhook receives the tool name, input, user identity, and timestamp, and returns one of three actions:

interface PreHookResult {
  action: "approve" | "deny" | "modify";
  input?: any;      // Modified input (only when action === "modify")
  reason?: string;  // Denial reason (only when action === "deny")
}

Approve — tool call proceeds normally. Deny — tool call is blocked, the reason is returned to the agent. Modify — tool call proceeds, but with the modified input your webhook returned. This lets you sanitize, redact, or transform input before it hits the backend.

Pre-hooks are signed with HMAC-SHA256 so your webhook can verify the request is from ACP:

POST https://your-compliance-system.example.com/hook
Content-Type: application/json
X-Hook-Signature: <HMAC-SHA256(body, secret)>
X-Hook-Event: pre

{
  "event": "pre",
  "tool": "salesforce.query",
  "input": {"query": "SELECT * FROM Account"},
  "tenantId": "acme-corp",
  "sub": "auth0|alice",
  "timestamp": "2026-04-01T14:23:09Z"
}

Pre-hooks are fail-open — if your webhook times out (default 5s) or returns a non-2xx status, the tool call proceeds. This prevents a misbehaving webhook from blocking all agent activity. If you need fail-closed behavior, configure the hook as a governance policy instead.

Post-hooks

Post-hooks fire after a tool executes, fire-and-forget. They receive the same payload plus the tool’s result and success/failure status. Use them for notifications, logging to external systems, or triggering downstream workflows.

Pattern matching

Hooks use glob patterns to match tool names:

  • "*" — matches all tools
  • "github.*" — matches all GitHub tools
  • "salesforce.query" — matches exactly one tool

This works across delegation chains. If a parent agent calls a child agent which calls github.repos.create, any hook with pattern "github.*" fires — regardless of how deep in the chain the call originated.


Creating Agents Programmatically

Agents can be created, managed, and invoked entirely through the public REST API — no dashboard required.

API key authentication

API keys use the format gsk_{slug}_{random}. The slug encodes the tenant, so API requests don’t need a tenant parameter — the key resolves the workspace automatically.

POST /api/v1/agents
Authorization: Bearer gsk_acmecorp_k8f7a2b1c9d4e5f6
Content-Type: application/json

{
  "name": "Onboarding Agent",
  "model": "gpt-4o",
  "systemPrompt": "You onboard new hires by creating accounts and notifying the team.",
  "enabledTools": ["github.*", "jira.*", "slack.postMessage"],
  "scopes": ["github.*", "jira.*", "slack.post"],
  "maxBudgetCents": 500,
  "maxToolCalls": 50,
  "delegatable": true,
  "canDelegate": true,
  "maxDelegationDepth": 3
}

The API supports full CRUD: POST /api/v1/agents (create), GET /api/v1/agents (list), GET /api/v1/agents/:id (get), PUT /api/v1/agents/:id (update), DELETE /api/v1/agents/:id (delete).

Triggering an agent run

POST /api/v1/agents/{profileId}/run
Authorization: Bearer gsk_acmecorp_k8f7a2b1c9d4e5f6

{
  "goal": "Onboard new hire Jamie Chen — create GitHub account, Jira access, send Slack welcome"
}

The agent runs autonomously, using the tools and scopes defined in its profile. If canDelegate is true, it can delegate sub-tasks to other agents — and each delegation creates a new link in the chain with narrowed permissions.

The creating principal’s identity is recorded on the agent profile (createdBy). When the agent runs, the audit trail records both the agent and the human or API key that created it — you always know who is accountable.


MCP Client: Consuming External Tools

ACP doesn’t just serve tools — it can also consume tools from external MCP servers. This means an agent in your workspace can call tools hosted by another MCP server, with the full governance stack applied to every call.

External MCP servers are configured per-tenant in Firestore. Each server gets a URL, transport type (Streamable HTTP or SSE), optional auth headers, and an enabled flag. When a tool call comes in, ACP connects to the server, discovers its tools, and namespaces them:

mcp.{serverId}.{toolName}

For example, if you connect a server called analytics that exposes a runQuery tool, agents see it as mcp.analytics.runQuery. The namespace prevents collisions with built-in tools and makes tool provenance clear in audit logs.

The connection pool manages lifecycle: connections have a 5-minute TTL, reconnect on failure, and cap concurrent servers per plan (Pro: 3, Enterprise: 10, Free: none). Every external tool call passes through the same seven-layer governance pipeline as built-in tools — immutable rules, delegation enforcement, scope checks, ABAC policies, rate limits, plan limits, and content scanning.

SSRF protection applies to MCP server URLs too. The system rejects servers with private/internal network addresses before connecting — the same isPrivateUrl check used in immutable rules.


Discovery via Well-Known Endpoints

Agents discover each other using the same well-known endpoint pattern used by OIDC providers and OAuth servers:

GET /.well-known/agent-card.json

The response follows the A2A protocol standard:

{
  "name": "ACP Agent Gateway (acme-corp)",
  "description": "Agentic Control Plane gateway for acme-corp workspace",
  "version": "1.0.0",
  "url": "https://api.makeagents.run/acme-corp",
  "provider": {
    "organization": "GatewayStack",
    "url": "https://agenticcontrolplane.com"
  },
  "capabilities": {
    "streaming": true,
    "pushNotifications": false
  },
  "securitySchemes": {
    "oauth2": { ... },
    "bearer": { ... }
  },
  "skills": [
    {
      "id": "crm-researcher",
      "name": "CRM Researcher",
      "description": "Queries Salesforce for account and contact data",
      "tags": ["salesforce.*"],
      "input_modes": ["text", "application/json"],
      "output_modes": ["text", "application/json"]
    },
    {
      "id": "ticket-creator",
      "name": "Ticket Creator",
      "description": "Creates and manages Jira tickets",
      "tags": ["jira.*"],
      "input_modes": ["text", "application/json"],
      "output_modes": ["text", "application/json"]
    }
  ]
}

Each skill in the card corresponds to a delegatable agent profile. External orchestrators can read the card, discover available agents, and invoke them through the standard MCP or A2A protocols — with full governance applied to every call.

Only agents with delegatable !== false appear in the card. Internal-only agents stay hidden.


Auditing Across Agent Hops

Every tool call in a delegation chain produces an audit record with full provenance:

{
  ts: "2026-04-01T14:23:09.412Z",
  tenantId: "acme-corp",
  requestId: "req_8c9d0e1f",
  tool: "github.createRepo",
  sub: "auth0|alice",               // always the human at the root
  ok: true,
  latencyMs: 847,
  agentProfileId: "provisioning-agent",
  agentRunId: "run_abc123",
  delegation: {
    originSub: "auth0|alice",       // the human who started everything
    depth: 2,
    chain: ["planning-agent", "provisioning-agent"],
    runChain: ["run_parent", "run_abc123"],
    parentProfileId: "planning-agent"
  },
  contentScan: {
    piiDetected: false,
    piiTypes: [],
    riskScore: 8
  }
}

From this single record, you can answer:

  • Who started it? Alice (originSub)
  • Which agents were involved? Planning Agent → Provisioning Agent (chain)
  • What action was taken? github.createRepo
  • Was it authorized? Yes (ok: true)
  • Was there sensitive data? No PII detected, risk score 8/100
  • How long did it take? 847ms
  • Can I trace the full execution? Yes — runChain links to both agent run documents, which contain conversation histories, tool call logs, and token usage

The delegation field is what makes this different from generic request logging. Every log entry in a delegation chain carries the full provenance — not just who made this call, but the entire chain of trust from the human at the root through every agent that participated.

Webhook Export

Audit events can be exported to external systems via HMAC-signed webhooks:

POST https://your-siem.example.com/webhook
Content-Type: application/json
x-gatewaystack-signature: <HMAC-SHA256(body, secret)>

{...log event...}

The webhook URL must be HTTPS and pass SSRF validation (no localhost, no private IPs, no metadata endpoints). The HMAC signature lets you verify that the event came from ACP and wasn’t tampered with.


What Makes This Different

Most agent frameworks handle orchestration — deciding which agent runs next. Most LLM gateways handle routing — picking which model provider to call.

Neither handles the governance question: who is this agent, what can it do, who delegated to it, and who is accountable?

ACP fills that gap with an architecture that’s governance-first:

  • Delegation chains are a first-class data structure — not an afterthought bolted on top of an orchestration framework
  • Scope narrowing is enforced server-side at every tool call. Permissions can only narrow through a chain, never widen
  • Immutable platform rules protect against PII leakage and SSRF at every depth, regardless of tenant configuration
  • Seven governance layers evaluate on every tool call: immutable rules, delegation enforcement, scopes, ABAC, rate limits, plan limits, and content scanning
  • Budget propagation tracks real model costs (not estimates) and prevents runaway spending
  • Pre/post hooks let you extend governance with your own approval workflows and monitoring
  • MCP client connectivity lets agents consume tools from external servers — with the same governance applied
  • Public API lets you create, manage, and invoke agents programmatically, with API key identity tracked through the full chain

This is the capability that lets you deploy multi-agent systems in production — in regulated industries, with real data, at scale — and still answer the compliance officer’s question: “Who did what, through which agents, and was it authorized?”


Get started → · Agent identity → · Architecture deep dive → · View on GitHub →