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.
Four places it fits
One recipe for each of the four connection points described on How it fits. All are optional. How it fits →
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.
decideActionreportExecutionOutcomegetGrantVerificationKey
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.
decideToolCallgetApprovalStatusreportExecutionOutcome
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.
decideToolCallgetApprovalStatusreportExecutionOutcome
Policy engines and security scanners
Scanner recipe (Microsoft 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
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.
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
end3. 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 })
end5. 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 AcrHandler6. 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.createWhat 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.
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.
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 body3. 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 wrap4. 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.
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 body3. 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 export5. 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 outcome6. 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.