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.
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. |
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. Helpers such as acr(), sha256Hex() and signedBytes() are a few lines each; the API reference has the exact formats.
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>;
};
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
try {
const result = await run();
await acr("reportExecutionOutcome", { decisionId: res.decision.decisionId, outcome: "executed" });
return result;
} catch (err) {
await acr("reportExecutionOutcome", { decisionId: res.decision.decisionId, outcome: "failed" });
throw err;
}
}No call back to Agent Control Room on the hot path.
// Check the grant locally with the published key (fetched once, cached).
async function verifyGrant(d: Grant, expected: Expected) {
if (Date.parse(d.expiresAt) <= Date.now()) throw new Error("expired");
if (d.payloadHash !== expected.payloadHash) throw new Error("payload mismatch");
if (d.agent !== expected.agent || d.action !== expected.action) throw new Error("wrong step");
if ((d.parentDecisionId ?? "") !== (expected.parentDecisionId ?? "")) throw new Error("wrong lineage");
const key = await publicKeyFor(d.keyId); // from getGrantVerificationKey
const ok = await crypto.subtle.verify("Ed25519", key, b64(d.publicSignature), signedBytes(d));
if (!ok) throw new Error("bad signature");
}
// For high-value steps, also call verifyDecision to catch a grant revoked before expiry.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 s = await acr("getApprovalStatus", { approvalId });
if (s.status === "pending") continue;
if (s.status === "granted") return acr("decideAction", request); // the claim
return { decision: { effect: "reject", reason: s.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