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 →
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
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
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
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
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.
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 = 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 })
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.
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.
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.
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 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: 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.
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 {}
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 report5. 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.
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.
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 accepts2. 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.