Plug-ins

Connect the systems you already run

A plug-in is a thin wrapper that runs in your own backend and asks Agent Control Room before an agent's step executes. Plug-ins are optional: a direct application connection uses exactly the same operations. Each recipe is a starting point, not a packaged product — review, test and adapt it to your platform before production. For MCP servers in TypeScript and for Python agents, supported open-source packages do the same job and are the recommended way to start.

Server-side only

Every plug-in runs on your server — in your MCP server process or backend service — and reads the API key from its environment. Never embed a key in browser JavaScript or mobile apps.

Four places it fits

One recipe for each of the four connection points described on How it fits, plus identity token verification, which works before all of them. All are optional. How it fits →

LuaAvailable

API and AI gateways

Kong Gateway plugin

Asks Agent Control Room before Kong forwards an agent's request, with an advanced option that checks a grant offline against the published Ed25519 key — in Lua, or with a JWT plugin that reads the public key set at /.well-known/jwks.json.

decideActionreportExecutionOutcomegetGrantVerificationKey

TypeScriptAvailable

MCP gateways

MCP server interceptor

Wraps each tool handler in your MCP server so it asks Agent Control Room before the tool runs and reports the outcome afterwards. Also available as the supported @complyee/mcp-guard package.

decideToolCallgetApprovalStatusreportExecutionOutcome

PythonAvailable

Agent orchestration and hand-offs

LangGraph tool node interceptor

Governs the tools in a LangGraph ToolNode, with an advanced option that adds the hand-off chain when one agent passes work to another. Also available as the supported complyee-agent-guard package, which includes a ready-made wrapper for CrewAI tools.

decideToolCallgetApprovalStatusreportExecutionOutcome

PythonAvailable

Policy engines and security scanners

Scanner recipe (Presidio, Lakera Guard)

Turns your own scanner's findings into standard security facts and asks before a data export runs, with an advanced option that adds a prompt-injection signal next to the personal-data one.

decideActionreportExecutionOutcome

TypeScriptAvailable

Identity · Before every place

Identity token verification

Verifies the sign-in token from your identity provider in your own backend and turns its verified claims into context facts, so rules rely on who is really asking — never on what the agent says. Works in front of all four recipes above.

Works with

  • Microsoft Entra ID
  • Okta
  • Any OpenID Connect provider

decideActiondecideToolCall

Kong Gateway plugin (Lua)

Use this when Kong already routes agent traffic to your APIs. The plugin asks Agent Control Room before Kong forwards each request. Kong routes, Agent Control Room decides, and your upstream service does the work.

Agent requestKong plugindecideActionUpstream servicereportExecutionOutcome

Before you start

  • Your workspace address: https://<workspace>.agent-cr.com/api/public/v1/gateway
  • An API key with the policy.decide scope, held in a Kong vault or environment variable — never as plain text in plugin config
  • The calling agents registered in your workspace, and rules for each mapped action
  • Kong Gateway 3.x with the lua-resty-http library (bundled with Kong)

1. Plugin configuration

The API key is a vault reference that Kong resolves at runtime. Each route can carry its own action map.

-- kong/plugins/acr/schema.lua
local typedefs = require "kong.db.schema.typedefs"

return {
  name = "acr",
  fields = {
    { protocols = typedefs.protocols_http },
    { config = { type = "record", fields = {
      -- https://<workspace>.agent-cr.com/api/public/v1/gateway
      { gateway_url = { type = "string", required = true } },
      -- A Kong vault reference such as {vault://env/acr-api-key}, never the key itself
      { api_key = { type = "string", required = true, referenceable = true, encrypted = true } },
      { agent_header = { type = "string", default = "X-Agent-Id" } },
      { action_header = { type = "string", default = "X-Acr-Action" } },
      -- "POST /payments/transfers" = "payments.transfer.create"
      { action_map = { type = "map", keys = { type = "string" }, values = { type = "string" }, default = {} } },
      { timeout_ms = { type = "integer", default = 3000 } },
    } } },
  },
}

2. Map the request to an action

A configured header wins. Otherwise the method and path are looked up in the action map. A request that matches nothing is refused.

-- handler.lua (part 1): which action is this request?
local function resolve_action(conf)
  local from_header = kong.request.get_header(conf.action_header)
  if from_header and from_header ~= "" then
    return from_header
  end
  local key = kong.request.get_method() .. " " .. kong.request.get_path()
  return conf.action_map[key]  -- nil when nothing matches
end

3. Fingerprint the body

Kong hashes the request body itself. Only the hash is sent to Agent Control Room; the body stays in your network.

-- handler.lua (part 2): one-way fingerprint of the body
local resty_sha256 = require "resty.sha256"
local str = require "resty.string"

local function payload_hash()
  local body = kong.request.get_raw_body() or ""
  local h = resty_sha256:new()
  h:update(body)
  return str.to_hex(h:final())  -- only this hash is sent, never the body
end

4. Ask before forwarding

A grant forwards the request with its decision id. A refusal returns 403. A hold returns 202 once with the approvalId. No answer returns 503 — the request is not forwarded.

-- handler.lua (part 3): ask before Kong forwards
local http = require "resty.http"
local cjson = require "cjson.safe"

local AcrHandler = { PRIORITY = 900, VERSION = "0.1.0" }

local function acr_call(conf, operation, parameters, idem_key)
  local client = http.new()
  client:set_timeout(conf.timeout_ms)
  local res, err = client:request_uri(conf.gateway_url, {
    method = "POST",
    body = cjson.encode({ operation = operation, parameters = parameters }),
    headers = {
      ["content-type"] = "application/json",
      ["x-api-key"] = conf.api_key,  -- resolved from the vault by Kong
      ["idempotency-key"] = idem_key,
    },
  })
  if not res then return nil, err end
  return cjson.decode(res.body)
end

function AcrHandler:access(conf)
  local action = resolve_action(conf)
  if not action then
    return kong.response.exit(403, { message = "No governed action mapped for this route." })
  end
  local hash = payload_hash()
  local params = {
    agent = kong.request.get_header(conf.agent_header),
    action = action,
    resourceRef = kong.request.get_path(),
    payloadHash = hash,
  }
  local idem = "kong-" .. ngx.md5(cjson.encode(params))
  local answer, err = acr_call(conf, "decideAction", params, idem)
  if not answer then
    kong.log.err("ACR unreachable: ", err)
    return kong.response.exit(503, { message = "Not forwarded: no decision." })  -- fail closed
  end
  local d = answer.decision or {}
  if d.effect == "grant" then
    kong.ctx.plugin.decision = { decisionId = d.decisionId, payloadHash = hash }  -- the hash Kong computed
    kong.service.request.set_header("X-Acr-Decision-Id", d.decisionId)
    return  -- forward to the upstream service
  end
  if d.effect == "pending" then
    -- Held for a person. Return once; the caller polls getApprovalStatus. No loop here.
    return kong.response.exit(202, { message = "Held for review.", approvalId = d.approvalId or answer.approvalId })
  end
  return kong.response.exit(403, { message = "Refused.", rationale = d.rationale })
end

5. Report the outcome

After the upstream answers, Kong reports executed or downstream_error on a background timer. The report is evidence only and never delays the response.

-- handler.lua (part 4): report what happened, without delaying the response
local function report(premature, conf, decision, outcome)
  if premature then return end
  local _, err = acr_call(conf, "reportExecutionOutcome", {
    decisionId = decision.decisionId,
    payloadHash = decision.payloadHash,
    outcome = outcome,
  }, "outcome-" .. decision.decisionId)
  if err then kong.log.warn("reportExecutionOutcome failed: ", err) end  -- evidence only
end

function AcrHandler:log(conf)
  local decision = kong.ctx.plugin.decision
  if not decision then return end
  local status = kong.response.get_status()
  local outcome = (status < 500) and "executed" or "downstream_error"
  ngx.timer.at(0, report, conf, decision, outcome)
end

return AcrHandler

6. Enable it on a route

A declarative snippet that governs one route. Add one action_map entry per governed endpoint.

# kong.yml (declarative) — enable on the route that agents call
_format_version: "3.0"
services:
  - name: payments
    url: http://payments.internal:8080
    routes:
      - name: transfers
        paths: ["/payments/transfers"]
        plugins:
          - name: acr
            config:
              gateway_url: https://<workspace>.agent-cr.com/api/public/v1/gateway
              api_key: "{vault://env/acr-api-key}"
              action_map:
                "POST /payments/transfers": payments.transfer.create

What it does not do

  • It does not send request bodies to Agent Control Room; only a fingerprint.
  • It never grants on its own. Only Agent Control Room's rules decide.
  • It does not replace Kong's own authentication, rate limiting or routing.
  • It does not wait in a loop on holds, and refuses when Agent Control Room cannot be reached.

TypeScript MCP server interceptor

Use this when your agents reach business systems through MCP tools that you host. The interceptor sits inside your MCP server, between the incoming tools/call and your existing tool handler.

MCP client calls a toolInterceptordecideToolCallTool handlerreportExecutionOutcome

Recommended

Supported companion: @complyee/mcp-guard

The recommended way to govern MCP tools. Install the package and wrap each governed tool when you register it; ungoverned tools stay as they are.

npm install @complyee/mcp-guard
import { createMcpGuard } from "@complyee/mcp-guard";

const guard = createMcpGuard({
  workspaceUrl: "https://<workspace>.agent-cr.com", // your workspace address, also the grant token's audience
  apiKey: process.env.ACR_API_KEY!,                  // scopes: policy.decide, policy.read
  agent: process.env.ACR_AGENT!,
});

server.registerTool(
  "transfer_funds",
  {
    description: "Transfer money between two accounts",
    inputSchema: { fromAccount: z.string(), toAccount: z.string(), amount: z.number() },
  },
  guard.tool("transfer_funds", {
    context: (args) => ({ amount: args.amount }),    // facts your rules read
    execute: async ({ fromAccount, toAccount, amount }, grant) => {
      // Runs only on a verified grant for exactly these arguments.
      await bank.transfer(fromAccount, toAccount, amount, { idempotencyKey: grant.decisionId });
      return { content: [{ type: "text", text: "Transfer completed." }] };
    },
  }),
);

What it takes care of

  • Fingerprints the tool name and arguments in your server and calls decideAction with only the hash — the arguments never leave your server.
  • Checks the grant token (EdDSA) against the published key set at https://agent-cr.com/.well-known/jwks.json: issuer, your workspace address, agent, action, payload fingerprint and expiry. Any failed check means the tool does not run.
  • When a person must decide, waits a short while (configurable) or returns at once with the approval id; calling the tool again with the same arguments claims the grant.
  • Retries a lost connection with the same idempotency key and reports executed or downstream_error afterwards.

@complyee/mcp-guard on npm →@complyee/mcp-guard on GitHub →

Or build it by hand

Prefer no dependency, or a runtime the package does not cover? The steps below build the same gate by hand. They use decideToolCall, which sends the arguments for fingerprinting; the package hashes them locally instead.

Before you start

  • Your workspace address: https://<workspace>.agent-cr.com/api/public/v1/gateway
  • An API key with the policy.decide scope, stored as a server-side secret
  • The agent registered in your workspace
  • Rules for the tool names you govern (the tool name is used as the action unless you pass one)

1. Configuration from the environment

The key is read from the server's environment. It never appears in source code or in anything sent to a browser.

// acr.ts — server-side only. Never import this into browser code.
const ACR_GATEWAY_URL = process.env.ACR_GATEWAY_URL!; // https://<workspace>.agent-cr.com/api/public/v1/gateway
const ACR_API_KEY = process.env.ACR_API_KEY!;         // kept in your secret store
const ACR_AGENT = process.env.ACR_AGENT!;             // the registered agent this server acts for

2. One helper for the gateway

Every operation goes through the same gateway address. A short timeout keeps a slow answer from stalling the agent.

type AcrResponse = Record<string, any> & { statusCode: number };

async function acrCall(
  operation: string,
  parameters: Record<string, unknown>,
  idempotencyKey?: string,
): Promise<AcrResponse> {
  const res = await fetch(ACR_GATEWAY_URL, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": ACR_API_KEY,
      ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
    },
    body: JSON.stringify({ operation, parameters }),
    signal: AbortSignal.timeout(5000),
  });
  const body = (await res.json()) as AcrResponse;
  if (!res.ok) throw new Error(`ACR ${operation} failed: ${body.error?.code ?? res.status}`);
  return body;
}

3. Wrap the tool handler

Ask before running. A grant runs the handler; a refusal returns an error result to the agent; a hold returns once with its holdToken. If Agent Control Room cannot be reached, the tool does not run.

type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean };
type Handler<A> = (args: A) => Promise<ToolResult>;

const blocked = (text: string): ToolResult => ({ content: [{ type: "text", text }], isError: true });

export function governTool<A extends Record<string, unknown>>(
  toolName: string,
  handler: Handler<A>,
  options: { context?: Record<string, unknown> } = {},
): Handler<A> {
  return async (args) => {
    let answer: AcrResponse;
    try {
      answer = await acrCall("decideToolCall", {
        agent: ACR_AGENT,
        toolName,
        toolArguments: args,
        ...(options.context ? { context: options.context } : {}),
      }, crypto.randomUUID());
    } catch {
      // Fail closed: no answer means no execution.
      return blocked("Agent Control Room could not be reached, so the tool did not run.");
    }

    const d = answer.decision;
    if (answer.mcp?.holdToken) {
      // Held for a person. Return once; do not retry in a loop.
      return blocked(`Held for human review. holdToken: ${answer.mcp.holdToken}`);
    }
    if (!answer.mcp?.allowExecution || d?.effect !== "grant") {
      return blocked(`Refused by policy: ${d?.rationale ?? "not allowed"}`);
    }

    let result: ToolResult;
    try {
      result = await handler(args);
    } catch (err) {
      void reportOutcome(d, "downstream_error", "tool_handler_error");
      throw err;
    }
    void reportOutcome(d, result.isError ? "downstream_error" : "executed");
    return result;
  };
}

4. Report what happened

After the handler runs, report executed, aborted or downstream_error. The report is evidence only and never delays or changes the tool's result.

async function reportOutcome(
  decision: { decisionId: string; payloadHash: string },
  outcome: "executed" | "aborted" | "downstream_error",
  detailCode?: string,
) {
  try {
    await acrCall("reportExecutionOutcome", {
      decisionId: decision.decisionId,
      payloadHash: decision.payloadHash,
      outcome,
      ...(detailCode ? { detailCode } : {}),
    }, `outcome-${decision.decisionId}`);
  } catch (err) {
    // Evidence only: a failed report never changes the tool's result.
    console.error("reportExecutionOutcome failed", err);
  }
}

5. Resume after a person decides (optional)

Check a hold with getApprovalStatus when it suits your flow. Do not poll in a tight loop.

// Later — for example when the agent or a person asks again — check the hold.
export async function approvalStatus(holdToken: string) {
  const answer = await acrCall("getApprovalStatus", { approvalId: holdToken });
  return answer.approval?.status as
    "pending" | "granted" | "refused" | "expired" | "consumed";
}
// When it is "granted", repeat the identical tool call once:
// that decideToolCall claims the approval and returns the signed grant.

6. Register the wrapped tool

Wrap each governed tool with governTool when you register it with the official MCP TypeScript SDK. Ungoverned tools stay as they are.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { governTool } from "./acr";

const server = new McpServer({ name: "payments", version: "1.0.0" });

server.tool(
  "transfer_funds",
  { fromAccount: z.string(), toAccount: z.string(), amount: z.number() },
  governTool("transfer_funds", async ({ fromAccount, toAccount, amount }) => {
    await bank.transfer(fromAccount, toAccount, amount); // your existing code
    return { content: [{ type: "text", text: "Transfer completed." }] };
  }),
);

Optional: to bind the call you forward to the grant, recompute the sha256-canonical-json-v1 fingerprint of { tool, arguments } and check it equals decision.payloadHash, then verify publicSignature offline with the key from getGrantVerificationKey, using your agent key and decision.action (both are signed). The API reference has the full recipe. API reference →

What it does not do

  • It does not scan or store tool arguments; Agent Control Room only uses them to compute a fingerprint.
  • It never grants on its own. Only Agent Control Room's rules decide.
  • It does not retry held calls in a loop.
  • It fails closed: no answer means the tool does not run.

Python / LangGraph tool node interceptor

Use this when your agents run as LangGraph graphs and call tools through a ToolNode. Each governed tool asks Agent Control Room before its body runs.

Agent nodeGoverned tool nodedecideToolCallTool runsreportExecutionOutcome

Recommended

Supported companion: complyee-agent-guard

The recommended way to govern Python agent tools. Add the decorator under @tool; ungoverned tools stay as they are. It works the same for LangChain tools and plain functions; CrewAI has its own wrapper, shown next.

pip install complyee-agent-guard
import os
from langchain_core.tools import tool
from complyee_agent_guard import create_guard

guard = create_guard(
    workspace_url="https://<workspace>.agent-cr.com",  # your workspace address, also the grant token's audience
    api_key=os.environ["ACR_API_KEY"],                 # scopes: policy.decide, policy.read
    agent=os.environ["ACR_AGENT"],
    approvals={"mode": "raise"},                       # hand holds to LangGraph to checkpoint and resume
)

@tool
@guard.tool(action="transfer_funds", context=lambda a: {"amount": a["amount"]})
def transfer_funds(from_account: str, to_account: str, amount: float) -> str:
    """Transfer money between two accounts."""
    bank.transfer(from_account, to_account, amount)  # runs only on a verified grant
    return "Transfer completed."

# Put transfer_funds in your ToolNode as usual. An async def tool is awaited the same way.

What it takes care of

  • Fingerprints the tool name and arguments in your process and calls decideAction with only the hash — the arguments never leave your process.
  • Checks the grant token (EdDSA) against the published key set at https://agent-cr.com/.well-known/jwks.json: issuer, your workspace address, agent, action, payload fingerprint and expiry. Any failed check raises an error and the tool body never runs.
  • Supports async def and plain def tools. When a person must decide it either waits a short while or raises ApprovalPending with a checkpoint your graph can save and resume later.
  • Retries a lost connection with the same idempotency key and reports executed or downstream_error afterwards.

complyee-agent-guard on PyPI →complyee-agent-guard on GitHub →

Recommended · complyee-agent-guard

Using CrewAI? Wrap the tool instead

The same package includes a wrapper for CrewAI. Wrap any CrewAI tool — one you wrote with @tool, a BaseTool class or a tool from crewai_tools — and give the wrapped tool to your agent. Ungoverned tools stay as they are.

pip install "complyee-agent-guard[crewai]"
import os
from crewai import Agent
from crewai.tools import tool
from complyee_agent_guard import create_guard
from complyee_agent_guard.crewai import GuardedCrewAITool, current_grant

guard = create_guard(
    workspace_url="https://<workspace>.agent-cr.com",  # your workspace address, also the grant token's audience
    api_key=os.environ["ACR_API_KEY"],                 # scopes: policy.decide, policy.read
    agent=os.environ["ACR_AGENT"],
)

@tool("issue_refund")
def issue_refund(payment_id: str, amount: float) -> str:
    """Refund a payment."""
    grant = current_grant()  # the verified grant for exactly these arguments
    payments.refund(payment_id, amount, idempotency_key=grant.decision_id)
    return "Refund issued."

refund = GuardedCrewAITool(
    tool=issue_refund,
    guard=guard,
    action="refund.issue",
    context=lambda a: {"amount": a["amount"]},       # facts your rules read
)

agent = Agent(role="Support lead", goal="...", backstory="...", tools=[refund])

What it takes care of

  • The agent sees the tool's own name, description and inputs. The inputs are fingerprinted in your process and never sent to Agent Control Room.
  • When the step does not run, the tool tells the agent why in plain words instead of crashing the crew, with a stable code: acr_refused, acr_approval_pending, acr_grant_invalid or acr_unavailable. Your CrewAI tool_failure_policy decides what happens next — "warn" lets the agent carry on and explain, "raise" stops the crew.
  • If a person has not approved yet and the agent asks again with exactly the same input, the same approval request continues; no second request is opened.
  • CrewAI never reuses a saved result for a wrapped tool, so every call gets its own decision.

Or build it by hand

Prefer no dependency, or a framework the package does not cover? The steps below build the same gate by hand. They use decideToolCall, which sends the arguments for fingerprinting; the package hashes them locally instead.

Before you start

  • Your workspace address: https://<workspace>.agent-cr.com/api/public/v1/gateway
  • An API key with the policy.decide scope, stored as a server-side secret
  • The agent registered in your workspace
  • Rules for the tool names you govern
  • Python packages: pip install langgraph httpx

1. Configuration from the environment

The key is read from the server's environment. It never appears in source code.

# acr.py — server-side only. Requires: pip install langgraph httpx
import os, uuid
import httpx

ACR_GATEWAY_URL = os.environ["ACR_GATEWAY_URL"]  # https://<workspace>.agent-cr.com/api/public/v1/gateway
ACR_API_KEY = os.environ["ACR_API_KEY"]          # kept in your secret store
ACR_AGENT = os.environ["ACR_AGENT"]              # the registered agent this graph acts for

2. One helper for the gateway

Every operation goes through the same gateway address, with a five-second timeout.

_client = httpx.Client(timeout=5.0)

def acr_call(operation: str, parameters: dict, idempotency_key: str | None = None) -> dict:
    headers = {"content-type": "application/json", "x-api-key": ACR_API_KEY}
    if idempotency_key:
        headers["idempotency-key"] = idempotency_key
    res = _client.post(ACR_GATEWAY_URL, headers=headers,
                       json={"operation": operation, "parameters": parameters})
    body = res.json()
    if res.is_error:
        raise RuntimeError(f"ACR {operation} failed: {body.get('error', {}).get('code', res.status_code)}")
    return body

3. Wrap the tool

A grant runs the tool. A refusal or a hold becomes a tool error the agent can read; a hold carries its holdToken. If Agent Control Room cannot be reached, the tool does not run.

import functools
from langchain_core.tools import ToolException

def govern_tool(tool_name: str, context_fn=None):
    """Decorator: ask Agent Control Room before the tool body runs."""
    def wrap(fn):
        @functools.wraps(fn)
        def governed(**args):
            params = {"agent": ACR_AGENT, "toolName": tool_name, "toolArguments": args}
            if context_fn:
                params["context"] = context_fn()
            try:
                answer = acr_call("decideToolCall", params, str(uuid.uuid4()))
            except Exception:
                # Fail closed: no answer means no execution.
                raise ToolException("Agent Control Room could not be reached, so the tool did not run.")

            d = answer.get("decision") or {}
            mcp = answer.get("mcp") or {}
            if mcp.get("holdToken"):
                # Held for a person. Return once; do not retry in a loop.
                raise ToolException(f"Held for human review. holdToken: {mcp['holdToken']}")
            if not mcp.get("allowExecution") or d.get("effect") != "grant":
                raise ToolException(f"Refused by policy: {d.get('rationale', 'not allowed')}")

            try:
                result = fn(**args)
            except Exception:
                report_outcome(d, "downstream_error", "tool_handler_error")
                raise
            report_outcome(d, "executed")
            return result
        return governed
    return wrap

4. Report what happened

After the tool runs, report executed or downstream_error. The report is evidence only.

def report_outcome(decision: dict, outcome: str, detail_code: str | None = None) -> None:
    params = {"decisionId": decision["decisionId"], "payloadHash": decision["payloadHash"], "outcome": outcome}
    if detail_code:
        params["detailCode"] = detail_code
    try:
        acr_call("reportExecutionOutcome", params, f"outcome-{decision['decisionId']}")
    except Exception as err:
        # Evidence only: a failed report never changes the tool's result.
        print("reportExecutionOutcome failed", err)

5. Put the governed tools in a ToolNode

Decorate each governed tool and wire them into your graph as usual. Ungoverned tools stay as they are.

from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition

@tool
@govern_tool("transfer_funds")
def transfer_funds(from_account: str, to_account: str, amount: float) -> str:
    """Transfer money between two accounts."""
    bank.transfer(from_account, to_account, amount)  # your existing code
    return "Transfer completed."

tools = [transfer_funds]
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)  # your existing model node, bound to tools
graph.add_node("tools", ToolNode(tools, handle_tool_errors=True))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")
app = graph.compile(checkpointer=checkpointer)

6. Resume after a person decides (optional)

LangGraph's interrupt pauses the graph instead of polling. When someone resumes it, check the hold once.

from langgraph.types import interrupt

def wait_for_person(hold_token: str) -> str:
    """Pause the graph until someone resumes it, then check the hold once."""
    interrupt({"holdToken": hold_token})  # the graph stops here; resume with Command(resume=...)
    answer = acr_call("getApprovalStatus", {"approvalId": hold_token})
    return answer["approval"]["status"]  # pending | granted | refused | expired | consumed
# When it is "granted", repeat the identical tool call once:
# that decideToolCall claims the approval and returns the signed grant.

What it does not do

  • It does not scan or store tool arguments; Agent Control Room only uses them to compute a fingerprint.
  • It never grants on its own. Only Agent Control Room's rules decide.
  • It does not retry held calls in a loop.
  • It fails closed: no answer means the tool does not run.

Python scanner recipe: Presidio, with optional Lakera Guard

Use this when you already scan content before an agent exports or publishes it. Your scanner runs in your system; its findings travel to Agent Control Room as standard security facts, and your rules decide.

Content to exportPresidio scanSecurity factsdecideActionExport runs

Before you start

  • Your workspace address: https://<workspace>.agent-cr.com/api/public/v1/gateway
  • An API key with the policy.decide scope, stored as a server-side secret
  • The agent registered in your workspace, and rules for data.export
  • Python packages: pip install presidio-analyzer httpx (plus a spaCy language model)

1. Configuration from the environment

Keys are read from the server's environment, never from source code.

# acr_scan.py — server-side only.
# Requires: pip install presidio-analyzer httpx && python -m spacy download en_core_web_lg
import os, json, hashlib, uuid
from datetime import datetime, timezone
import httpx

ACR_GATEWAY_URL = os.environ["ACR_GATEWAY_URL"]  # https://<workspace>.agent-cr.com/api/public/v1/gateway
ACR_API_KEY = os.environ["ACR_API_KEY"]          # kept in your secret store
ACR_AGENT = os.environ["ACR_AGENT"]              # the registered agent this service acts for

2. One helper for the gateway

The same helper as the LangGraph recipe, with a five-second timeout.

_client = httpx.Client(timeout=5.0)

def acr_call(operation: str, parameters: dict, idempotency_key: str | None = None) -> dict:
    headers = {"content-type": "application/json", "x-api-key": ACR_API_KEY}
    if idempotency_key:
        headers["idempotency-key"] = idempotency_key
    res = _client.post(ACR_GATEWAY_URL, headers=headers,
                       json={"operation": operation, "parameters": parameters})
    body = res.json()
    if res.is_error:
        raise RuntimeError(f"ACR {operation} failed: {body.get('error', {}).get('code', res.status_code)}")
    return body

3. Scan locally and map findings to facts

Presidio runs in your system. Its findings become sec.pii_detected, sec.pii_categories_band and sec.dlp_verdict, each with its own source and time. A failed scan sends not_scanned, never clean.

from presidio_analyzer import AnalyzerEngine

_analyzer = AnalyzerEngine()
FINANCIAL = {"CREDIT_CARD", "IBAN_CODE", "US_BANK_NUMBER"}
CONTACT = {"PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "LOCATION"}
SPECIAL = {"MEDICAL_LICENSE"}  # extend with your own recognizers

def fact(value, asserted_by: str) -> dict:
    """Each fact names its own source and time."""
    return {"value": value, "assertedBy": asserted_by,
            "observedAt": datetime.now(timezone.utc).isoformat()}

def presidio_facts(text: str) -> dict:
    try:
        found = {r.entity_type for r in _analyzer.analyze(text=text, language="en") if r.score >= 0.6}
    except Exception:
        # A failed scan is unknown, never clean.
        return {"sec.dlp_verdict": fact("not_scanned", "presidio-analyzer")}
    band = ("special_category" if found & SPECIAL else "financial" if found & FINANCIAL
            else "contact" if found & CONTACT else "none")
    return {
        "sec.pii_detected": fact("true" if found else "false", "presidio-analyzer"),
        "sec.pii_categories_band": fact(band, "presidio-analyzer"),
        "sec.dlp_verdict": fact("flagged" if found else "clean", "presidio-analyzer"),
    }

4. Ask before the export

Only a one-way fingerprint of the content and the facts are sent. Anything other than a grant — a refusal, a hold for a person, or no answer — means the export does not run.

def ask_before_export(text: str, destination: str, context: dict) -> dict | None:
    payload_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()  # content never leaves you
    body = {"agent": ACR_AGENT, "action": "data.export", "resourceRef": destination,
            "payloadHash": payload_hash, "context": context}
    key = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
    try:
        answer = acr_call("decideAction", body, key)
    except Exception:
        return None  # Fail closed: no answer means no export.
    d = answer.get("decision") or {}
    if d.get("effect") != "grant":
        return None  # pending or reject: do not export
    return {**d, "payloadHash": payload_hash}  # keep your own hash for the outcome report

5. Export on a grant and report what happened

The report is evidence only and never changes the result.

def export_with_scan(text: str, destination: str) -> str:
    decision = ask_before_export(text, destination, presidio_facts(text))
    if decision is None:
        return "Not exported: refused, held for a person, or no answer."
    try:
        storage.export(destination, text)  # your existing code
        outcome = "executed"
    except Exception:
        outcome = "downstream_error"
    try:
        acr_call("reportExecutionOutcome", {"decisionId": decision["decisionId"],
                 "payloadHash": decision["payloadHash"], "outcome": outcome},
                 f"outcome-{decision['decisionId']}")
    except Exception as err:
        print("reportExecutionOutcome failed", err)  # evidence only
    return outcome

6. Write rules that use the facts

Rules live in your workspace. These examples refuse special-category data and unscanned content, and ask a person when something is flagged.

# Example rules you write in your workspace for data.export (not in code):
# 1. Refuse when sec.pii_categories_band is special_category.
# 2. Refuse when sec.dlp_verdict is not_scanned.
# 3. Ask a person first when sec.dlp_verdict is flagged.
# 4. Otherwise allow.

What it does not do

  • It does not send content to Agent Control Room; only verdicts and a fingerprint.
  • It never lets a scanner grant. Only Agent Control Room's rules decide.
  • It never treats a missing or failed scan as clean.
  • It does not retry in a loop, and fails closed when there is no answer.

Identity token verification (TypeScript)

Use this when people or services sign in through Microsoft Entra ID, Okta or another OpenID Connect provider, and your rules should depend on who is asking — their role, or whether a person or a service started the step. It runs in your backend, in front of any of the four recipes above, and turns a verified token into context facts.

Request with sign-in tokenVerify tokenIdentity factsdecideActionWork runs

Before you start

  • Your identity provider's issuer, its public key (JWKS) address, and the audience your API's tokens are issued for
  • Tokens issued for your own API — not for Microsoft Graph or another provider's API, which your backend cannot validate
  • The same workspace address, server-side API key and registered agent as the other recipes
  • Node.js 18 or later with the jose package: npm install jose

1. Configuration from the environment

Checking a token needs only your provider's public keys, so there is no client secret here. The comments show the Entra ID and Okta forms.

// identity.ts — server-side only. Requires: npm install jose
// Checking a token needs only your provider's public keys: no client secret here.
const IDP_ISSUER = process.env.IDP_ISSUER!;     // Entra ID: https://login.microsoftonline.com/<tenant-id>/v2.0
                                                // Okta:     https://<your-org>.okta.com/oauth2/default
const IDP_JWKS_URL = process.env.IDP_JWKS_URL!; // Entra ID: https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
                                                // Okta:     https://<your-org>.okta.com/oauth2/default/v1/keys
const IDP_AUDIENCE = process.env.IDP_AUDIENCE!; // your own API's client ID or application ID URI
const IDP_ROLE_CLAIM = process.env.IDP_ROLE_CLAIM ?? "roles"; // Entra app roles: "roles"; Okta: usually "groups"
const FINANCE_ROLE = process.env.FINANCE_ROLE!; // for example Finance.Approver
const ASSERTER = "identity-verifier";           // the asserter name your context contract accepts

2. Verify the token

jose checks the signature against the provider's published keys, plus issuer, audience and expiry. Keys are cached and rotation is followed automatically. Anything wrong throws.

import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";

// Fetches and caches the provider's public keys and follows key rotation.
const jwks = createRemoteJWKSet(new URL(IDP_JWKS_URL));

export async function verifyToken(authorization: string | null): Promise<JWTPayload> {
  const token = authorization?.match(/^Bearer (.+)$/i)?.[1];
  if (!token) throw new Error("no_token");
  const { payload } = await jwtVerify(token, jwks, {
    issuer: IDP_ISSUER,
    audience: IDP_AUDIENCE,
    algorithms: ["RS256"],
    requiredClaims: ["iat", "exp"],
    clockTolerance: 60,
  });
  return payload; // signature, issuer, audience and expiry all checked — or it threw
}

3. Turn verified claims into facts

Only the facts your rules need, each with who asserted it and when. The time comes from the token itself, so a context contract can turn away old sign-ins.

type Fact = { value: string | boolean; assertedBy: string; observedAt: string };

export function identityFacts(payload: JWTPayload): Record<string, Fact> {
  // When the provider issued these claims, so a contract can turn away old sign-ins.
  const observedAt = new Date(payload.iat! * 1000).toISOString();
  const fact = (value: string | boolean): Fact => ({ value, assertedBy: ASSERTER, observedAt });
  const roles = payload[IDP_ROLE_CLAIM];
  // Entra ID delegated tokens carry scp; Okta user tokens carry uid. Check your own provider's tokens.
  const isPerson = typeof payload.scp === "string" || typeof payload.uid === "string";
  return {
    "requester.kind": fact(isPerson ? "person" : "service"),
    "requester.finance_role": fact(Array.isArray(roles) && roles.includes(FINANCE_ROLE)),
  };
  // Send what the rules need — not names, email addresses or the token itself.
}

4. Ask with the facts

The token arrives with the request from your sign-in, never through the model. A bad token stops here. Otherwise the facts go with decideAction — or as context on decideToolCall in the MCP and LangGraph recipes.

import { createHash, randomUUID } from "node:crypto";
// acrCall and ACR_AGENT: the same configuration and gateway helper as the MCP recipe, steps 1–2.

export async function commitTransaction(req: Request): Promise<Response> {
  let context: Record<string, unknown>;
  try {
    context = identityFacts(await verifyToken(req.headers.get("authorization")));
  } catch {
    // Fail closed: a missing, invalid or expired token means no request and no work.
    return new Response("Sign-in could not be verified.", { status: 401 });
  }
  const body = await req.json();                           // the step the agent proposed
  context["effect.amount_band"] = amountBand(body.amount); // your own thresholds

  let answer;
  try {
    answer = await acrCall("decideAction", {
      agent: ACR_AGENT,
      action: "transaction.commit",
      payloadHash: createHash("sha256").update(JSON.stringify(body)).digest("hex"),
      context,                                             // verified facts only
    }, randomUUID());
  } catch {
    return new Response("No decision, so nothing ran.", { status: 503 }); // fail closed
  }
  const effect = answer.decision?.effect;
  if (effect === "pending") return new Response("Held for a person.", { status: 202 });
  if (effect !== "grant") return new Response("Refused by policy.", { status: 403 });
  // ...run the transaction and report the outcome as in the other recipes
}

5. Declare the facts and write rules

In your workspace, an enforced context contract makes the facts required and accepts them only from your verifier and only while fresh. Rules then use them like any other fact.

# Context contract for transaction.commit, set in your workspace (not in code):
# requester.kind          agreed list: person, service   required, provenance from identity-verifier, at most 5400 s old
# requester.finance_role  yes/no                         required, provenance from identity-verifier, at most 5400 s old
# Mode: enforced — a request without valid facts is undecidable: neither allowed nor refused.
#
# Example rules for transaction.commit:
# 1. Refuse when requester.kind is service.
# 2. Ask a person first when effect.amount_band is high.
# 3. Allow when requester.finance_role is true and effect.amount_band is low or medium.
# 4. Otherwise refuse (the default).

What it does not do

  • It does not connect Agent Control Room to your identity provider. Your backend verifies the token; Agent Control Room never sees it or calls your directory.
  • It does not send the token, names or email addresses — only the facts your rules use.
  • It does not replace sign-in, multi-factor authentication or your provider's own access policies.
  • It never grants on its own: a valid token only supplies facts, and a missing or invalid token stops the step before anything is asked.