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.

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.

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 = d.payloadHash }
    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

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. 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

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: Microsoft Presidio, with 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 {}
    return d if d.get("effect") == "grant" else None  # pending or reject: do not export

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.