Decision point — Agent Control Room
Holds the rules, the approval queue, the signed grants and the decision record. Answers one step at a time: allow, refuse, or ask a person first.
Building the enforcement point
An action that can run without asking is not governed, however good the rules are. This page describes the side you build: the platform that runs your agents, holds your data and credentials, and acts only on a valid grant.
Everything on How it fits and the plug-in recipes is a placement of this one pattern — in a backend, a gateway, an MCP proxy or an orchestrator.
Holds the rules, the approval queue, the signed grants and the decision record. Answers one step at a time: allow, refuse, or ask a person first.
Runs the agent, holds the data and the credentials, and carries out the work. Acts only on a signed, unexpired grant bound to exactly the work in front of it.
Behind your platform sit the systems that already know the truth about a step: your identity provider, customer records, account limits. Security architects call these policy information points (PIP). Your gate reads them and sends what it finds as context facts. Agent Control Room never connects to them itself, and the agent's own words never stand in for them.
Every privileged step — reading a file, calling a model, writing a record, sending a message, moving money — goes through one wrapper that asks, verifies, acts and reports. There is no second path.
Credentials, tool clients and network access sit behind the gate. The model can propose a step; it can never call the tool directly.
Check the signature, expiry, workspace, agent, action and the fingerprint of exactly what is about to run. For a hand-off, check the parent decision too.
No grant, an expired grant, a fingerprint that does not match or Agent Control Room unreachable: the step does not run.
Report the outcome after the step. A step held for a person waits and resumes when they answer; it is never retried in a loop.
These routes are closed by how your platform is built. They are your responsibility, not Agent Control Room's.
| Route around the gate | Platform control that closes it |
|---|---|
| The agent holds an API key or database password | Keep secrets in the gate's process only; give the agent no environment variables or files containing them. |
| A tool is registered without the wrapper | Build tool clients only through a factory that requires a verified grant; review new tools in code review. |
| Side channels: shell, open network, file system | Run agents in a sandbox with no outbound network except the gate; no general-purpose shell. |
| A step nobody thought to govern | Default-deny at the gate: an action without a declared name refuses, and so does an action with no rule. |
| A grant reused for different work | Bind each grant to the payload fingerprint and use it once; recompute the fingerprint at the moment of execution. |
| A retry runs the same step twice — the connection dropped after the downstream system had already acted | Agent Control Room makes asking safe to repeat, not acting. Pass the decision id as the downstream system's own idempotency key, or record that the step ran before you retry it. |
| The agent makes up its own context facts — who it acts for, its role, its limits | Build context facts in the gate from trusted systems: a validated token from your identity provider, your own records. Never pass along facts the model wrote. Agent Control Room judges the facts it is given; it cannot tell where they came from. |
Even an agent with strong reasons to cut corners cannot act without a grant, because the ability to act lives outside the agent. Agent Control Room does not try to read intent or detect deception; it makes the honest path the only path that executes.
The library curator agent in the Complyee Policy Library follows this pattern. For each document it asks before reading the file, asks again before calling the model, and asks a third time before writing the abstract. Each step carries its own grant bound to its own payload, and a refusal at any step stops the run with a recorded reason.
TypeScript, fictional names, trimmed to the essentials. These are skeletons to adapt and test in your own platform, not a packaged library. Helpers such as acr(), sha256Hex() and b64() are a few lines each; the API reference has the exact formats. For TypeScript MCP servers and Python agents you can instead use the supported @complyee/mcp-guard and complyee-agent-guard packages, which do the same work without assembling the gate by hand.
Ask, verify, act, report.
// One gate for every privileged step. Nothing else holds the credentials.
type Step<T> = {
agent: string; // e.g. "promo-writer"
action: string; // e.g. "campaign.publish"
resourceRef?: string; // e.g. "campaign:spring-2027"
payload: unknown; // exactly what is about to run
parentDecisionId?: string; // set when this step was handed on
context?: Record<string, unknown>; // built by the gate from your information points
// (validated identity token, your own records),
// never copied from what the model wrote
};
export async function withDecision<T>(step: Step<T>, run: () => Promise<T>): Promise<T> {
const payloadHash = await sha256Hex(canonicalJson(step.payload));
const request = { ...step, payload: undefined, payloadHash };
let res = await acr("decideAction", request);
if (res.decision.effect === "pending") {
res = await waitForPerson(res.decision.approvalId, request); // poll status, then claim once
}
if (res.decision.effect !== "grant") {
throw new PolicyDenied(res.decision); // refused: do nothing
}
await verifyGrant(res.decision, { ...step, payloadHash }); // throws on any mismatch
const report = (outcome: "executed" | "aborted" | "downstream_error") =>
acr("reportExecutionOutcome", {
decisionId: res.decision.decisionId,
payloadHash, // must match the hash the grant is bound to
outcome,
occurredAt: new Date().toISOString(),
});
try {
const result = await run();
await report("executed");
return result;
} catch (err) {
await report("downstream_error"); // use "aborted" if you chose not to run it
throw err;
}
}
// Calling the gate. Facts come from verified sources, never the LLM prompt:
const context = {
"requester.role": verifiedUser.role, // From verified IAM token
"customer.tenure_years": await getTenure(orgId), // From database/CRM
};
await withDecision(
{ agent: "refund-agent", action: "refund.issue", payload: refund, context },
() => payments.refund(refund),
);No call back to Agent Control Room on the hot path.
// Check the grant locally with the published key (fetched once, cached).
// The signature covers the agent, action and payload hash YOU expect, so a grant for other work fails.
async function verifyGrant(d: Grant, expected: Expected) {
if (Date.parse(d.expiresAt) <= Date.now()) throw new Error("expired");
const l = d.lineage; // null for a root grant
if ((l?.parentDecisionId ?? "") !== (expected.parentDecisionId ?? "")) throw new Error("wrong lineage");
const message = ["acr-grant-v3", workspaceId, d.keyId, d.decisionId,
expected.agent, expected.action, // who may do what, as this gate knows it
expected.payloadHash, // your own hash, never one read from the answer
d.expiresAt, l?.parentDecisionId ?? "", l?.rootDecisionId ?? d.decisionId,
String(l?.depth ?? 0)].join("|");
const key = await publicKeyFor(d.keyId); // from getGrantVerificationKey (also gives workspaceId)
const ok = await crypto.subtle.verify("Ed25519", key, b64(d.publicSignature),
new TextEncoder().encode(message));
if (!ok) throw new Error("bad signature"); // other agent, action, payload or lineage, or altered grant
}
// A service handed the grant later checks it the same way, with the agent and action it will serve.
// For high-value steps, also call verifyDecision to catch a grant revoked before expiry.For platforms that already verify JWTs against a published key set — same grant, standard tooling.
// Optional: your platform already checks JSON Web Tokens. Ask with grantFormat: "jwt";
// a signed grant then also carries decision.grantJwt — the same grant, same key, same expiry.
// jwtVerify and createRemoteJWKSet come from your standard JWT library.
const ACR_KEYS = createRemoteJWKSet(new URL("https://agent-cr.com/.well-known/jwks.json"));
async function verifyGrantJwt(d: Grant, expected: Expected) {
if (!d.grantJwt) throw new Error("no token"); // refusals and held steps never carry one
const { payload } = await jwtVerify(d.grantJwt, ACR_KEYS, {
algorithms: ["EdDSA"], // pin it; never accept what the token says
issuer: "https://agent-cr.com",
audience: WORKSPACE_ADDRESS, // https://<workspace>.agent-cr.com
subject: expected.agent,
}); // signature, expiry and key rotation handled here
const acr = payload.acr as GrantClaims;
if (acr.action !== expected.action) throw new Error("wrong action");
if (acr.payloadHash !== expected.payloadHash) throw new Error("wrong payload"); // your own hash
if ((acr.lineage.parentDecisionId ?? "") !== (expected.parentDecisionId ?? "")) throw new Error("wrong lineage");
}
// The signature proves Agent Control Room said yes; the comparisons prove it said yes to this work.
// Same limit as above: a local check cannot see a revocation — call verifyDecision where that matters.The exception path.
// A person is asked only where a rule says so. Park the step and resume.
// Poll getApprovalStatus (never decideAction) while pending; once granted,
// repeat the identical decideAction call once to claim the signed, single-use grant.
async function waitForPerson(approvalId: string, request: DecideRequest) {
for (let delay = 5_000; ; delay = Math.min(delay * 2, 15_000)) {
await sleep(delay);
const { approval } = await acr("getApprovalStatus", { approvalId });
if (approval.status === "pending") continue;
if (approval.status === "granted") return acr("decideAction", request); // the claim
return { decision: { effect: "reject", reason: approval.status } }; // refused, expired or consumed
}
}
// Claim within fifteen minutes of approval, and never change the payload while waiting.Authority narrows on every hop.
// Handing work to another agent: pass the parent decision, never a credential.
await withDecision(
{ agent: "lead-qualifier", action: "crm.update", payload: lead,
parentDecisionId: parentGrant.decisionId },
() => crm.update(lead),
);
// ACR narrows authority on every hop: depth, expiry and amounts can only shrink.decideActiongetApprovalStatusgetGrantVerificationKeyverifyDecisionreportExecutionOutcome