Reference blueprint

Anatomy of a governed agent

A complete agent, built the way Agent Control Room expects: it prepares its work, asks before each step, waits safely when a person must decide, and reports what happened. The pattern comes from the library curator agent in the Complyee Policy Library.

Start with the Standard tier and the agent is governed. Move to Zero-trust when a grant must prove itself to another service — one function changes, nothing else.

The code is TypeScript, trimmed to the pattern. Action names follow the curator; storage, model and job helpers are placeholders. The API reference has the exact formats.

The agent

For each new document in a library, the curator reads the file, asks a model for a short abstract and writes the abstract back. Each of those is a separate governed step with its own grant, bound to a fingerprint of exactly the work it is about to do. A refusal at any step stops the run for that document with a recorded reason.

Most documents pass without anyone being asked. A rule can hold the write for a person — for example when the abstract comes from a model outside the approved region — and the curator parks that document and moves on.

ActionGrant bound toContext factsWhat runs on a grant
document.readDocument id and the file's fingerprintNoneRead the file from storage
model.invokeFingerprints of the excerpt and the prompt, and the modelModel and region, from workspace settingsSend the excerpt to the model
library.writeFingerprint of the abstract and its versionModel and abstract version, frozen in the draftStore the abstract — may be held for a person

Two tiers: start simple, tighten when you need to

Both tiers use the same gate, the same fingerprint, the same hold-and-resume and the same outcome report. They differ only in how the gate checks a grant before acting.

Start here

Standard

The gate trusts the answer it just received over HTTPS from its own workspace address, and acts on it at once, in the same process.

  • Checks that the answer is a grant, unexpired, with the expected lineage.
  • Three operations: decideAction, getApprovalStatus, reportExecutionOutcome.
  • Fits pilots and agents whose gate both asks and acts.

When a grant must prove itself

Zero-trust

The gate also checks the grant's Ed25519 signature with Agent Control Room's published key, rebuilt from the agent, action and payload fingerprint it expects itself.

  • Works offline: no call back on the hot path once the key is cached.
  • Adds getGrantVerificationKey, and verifyDecision for revocation on high-value steps.
  • Fits grants handed to a queue worker, gateway or separate service, and regulated environments.
DimensionStandardZero-trust
What the gate trustsThe HTTPS answer from its own workspace addressOnly the signature, checked with the published key
Who can check the grantThe process that askedAny service that knows the agent, action and payload it will serve
What is checkedGrant, expiry, lineageAlso workspace, key, decision, agent, action and payload fingerprint, in one signature
Revocation before expiryNot seen; grants are short-lived and used at onceNot seen offline; call verifyDecision where it matters
What changes in code—One function: verifyGrant

The loop

Agent proposes a stepGate fingerprints the workAgent Control Room decidesGate checks the grantGate runs the stepOutcome reported

When a rule asks for a person, the answer is pending: the gate parks the step with its frozen request and the run moves on. A resume job reads the approval status; when it reads granted, the identical request is sent once more and returns a signed, single-use grant. Refused, expired and consumed are final, and nothing retries in a loop.

The code, step by step

  1. Step 1

    Fingerprint the work and call the API

    Two small helpers. The payload is hashed in your process; only the fingerprint is sent. Large content such as a file or an abstract is hashed first, so the payload stays small and holds no text.

    Helpers
    // The fingerprint binds a grant to exactly this work.
    // Keys sorted at every level, no whitespace, then SHA-256 as lowercase hex.
    function canonicalJson(v: unknown): string {
      if (v === null || typeof v !== "object") return JSON.stringify(v);
      if (Array.isArray(v)) return "[" + v.map(canonicalJson).join(",") + "]";
      const o = v as Record<string, unknown>;
      return "{" + Object.keys(o).filter((k) => o[k] !== undefined).sort()
        .map((k) => JSON.stringify(k) + ":" + canonicalJson(o[k])).join(",") + "}";
    }
    
    async function sha256Hex(text: string): Promise<string> {
      const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
      return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join("");
    }
    
    // One call helper. The workspace key lives in the gate's process, never the agent's.
    const ACR_URL = "https://your-workspace.agent-cr.com/api/public/v1/gateway";
    
    async function acr(operation: string, parameters: object) {
      const res = await fetch(ACR_URL, {
        method: "POST",
        headers: { "content-type": "application/json", "x-api-key": process.env.ACR_API_KEY! },
        body: JSON.stringify({ operation, parameters }),
      });
      if (!res.ok) throw new Error("Agent Control Room answered " + res.status); // fail closed
      return res.json();
    }
  2. Step 2

    One gate for every step

    Ask, check, run, report. The same wrapper serves both tiers; only verifyGrant differs. The agent never holds the credentials for storage, the model or the library — the gate does.

    Looking for a supported package instead of assembling the gate yourself? For TypeScript MCP servers, npm install @complyee/mcp-guard; for Python agents, pip install complyee-agent-guard. Both package the same local fingerprinting, grant-token check against the published key set, fail-closed handling, hold-and-resume and outcome reporting shown on this page.

    The gate
    type Step = {
      agent: string;                       // "library-curator"
      action: string;                      // "document.read", "model.invoke", "library.write"
      resourceRef?: string;                // the document id
      payload: unknown;                    // exactly what is about to run — hashed, never sent
      context: Record<string, unknown>;    // built here from trusted sources; {} when none
      parentDecisionId?: string;           // only when this step was handed on
    };
    
    export class Held extends Error {      // a person must decide first
      constructor(readonly approvalId: string) { super("held for a person"); }
    }
    
    export async function withDecision<T>(step: Step, run: () => Promise<T>): Promise<T> {
      const payloadHash = await sha256Hex(canonicalJson(step.payload));
      const { payload, ...ask } = step;                 // the payload itself never leaves
      const d = (await acr("decideAction", { ...ask, payloadHash })).decision;
    
      if (d.effect === "pending") throw new Held(d.approvalId); // park it; never loop
      if (d.effect !== "grant") throw new PolicyDenied(d);      // refused or not decided: do nothing
      await verifyGrant(d, { ...step, payloadHash });           // Standard or Zero-trust, Step 5
    
      const report = (outcome: "executed" | "aborted" | "downstream_error") =>
        acr("reportExecutionOutcome", {
          decisionId: d.decisionId,
          payloadHash,                                  // 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");               // "aborted" if you chose not to run it
        throw err;
      }
    }
  3. Step 3

    The curator's three steps

    Each step names its action, binds the grant to its own payload and builds its context from trusted sources: workspace settings and the document record, never the model's output.

    The curator
    // One document, three governed steps. Each has its own grant for its own work.
    const agent = "library-curator";
    
    async function curate(doc: Doc) {
      // 1. Read the file — bound to the file's own fingerprint.
      const text = await withDecision(
        { agent, action: "document.read", resourceRef: doc.id,
          payload: { documentId: doc.id, fileHash: doc.sha256 }, context: {} },
        () => storage.read(doc.path),
      );
    
      // 2. Call the model — bound to the exact excerpt and prompt that go out.
      const abstract = await withDecision(
        { agent, action: "model.invoke", resourceRef: doc.id,
          payload: { documentId: doc.id, model: settings.model,
                     excerptHash: await sha256Hex(text), promptHash: await sha256Hex(PROMPT) },
          context: { model: settings.model, region: settings.region } }, // from settings, not the model
        () => llm.summarise(text, PROMPT),
      );
    
      // 3. Write the abstract — the step a rule may hold for a person.
      await writeAbstract(doc, { abstract, version: doc.abstractVersion + 1, model: settings.model });
    }
  4. Step 4

    When a person must decide

    The exception path. The curator saves the draft with the approval id and ends the run for that document. A resume job polls the status and, once granted, asks again with the identical request — the claim.

    An interactive application can poll instead of parking; the enforcement point guide shows that variant.

    Park and resume
    // Everything in the request comes from the frozen draft, so asking again
    // later sends the identical request — that is what claims the approval.
    async function writeAbstract(doc: Doc, draft: Draft) {
      try {
        await withDecision(
          { agent, action: "library.write", resourceRef: doc.id,
            payload: { documentId: doc.id, abstractHash: await sha256Hex(draft.abstract),
                       version: draft.version },
            context: { model: draft.model, abstract_version: "v" + draft.version } },
          () => library.save(doc.id, draft),
        );
      } catch (err) {
        if (!(err instanceof Held)) throw err;
        await jobs.park({ docId: doc.id, approvalId: err.approvalId, draft }); // the run ends here
      }
    }
    
    // A scheduled resume job. A batch worker never sleeps waiting for a person.
    async function resumeParked() {
      for (const p of await jobs.listParked()) {
        const { approval } = await acr("getApprovalStatus", { approvalId: p.approvalId });
        if (approval.status === "pending") continue;           // nobody has answered yet
        if (approval.status === "granted") {
          await writeAbstract(await docs.get(p.docId), p.draft); // identical request = the claim
        }
        await jobs.close(p, approval.status);   // refused, expired or consumed: stop and record why
      }
    }
    // Claim within fifteen minutes of approval. Poll getApprovalStatus, never decideAction.
  5. Step 5

    Checking the grant — pick a tier

    Both versions fail closed. Swap the first for the second when a grant must be checkable by itself; nothing else in the agent changes.

    The Zero-trust check below needs no extra libraries. If your platform already checks standard tokens, it can instead add grantFormat: "jwt" to the request and check decision.grantJwt against the public key list at /.well-known/jwks.json with a standard JWT library — the same grant, key and expiry; the enforcement point guide shows that variant.

    Standard tier
    // Standard tier. Trust the answer you just received over HTTPS from your own
    // workspace address, and use it at once, in the process that asked.
    async function verifyGrant(d: Decision, expected: Expected) {
      if (d.effect !== "grant" || !d.decisionId) throw new Error("no grant");
      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");
      // The grant is bound to the payloadHash you sent. If what run() touches could
      // have changed since you asked, recompute the hash now and compare.
    }
    Zero-trust tier
    // Zero-trust tier. Check the Ed25519 signature with the published key, so the
    // grant proves itself — to this process, a queue worker or a separate service.
    let keys: Promise<VerificationKeys> | undefined;    // fetched once, cached
    const loadKeys = () =>
      (keys ??= acr("getGrantVerificationKey", {}).then((r) => r.verificationKeys));
    
    async function verifyGrant(d: Decision, 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 vk = await loadKeys();                       // refetch once on an unknown keyId (rotation)
      const k = vk.keys.find((x) => x.keyId === d.keyId);
      if (!k || !d.publicSignature) throw new Error("unknown key or unsigned grant");
    
      const message = ["acr-grant-v3", vk.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 crypto.subtle.importKey("jwk", k.jwk, { name: "Ed25519" }, false, ["verify"]);
      const ok = await crypto.subtle.verify("Ed25519", key, base64UrlToBytes(d.publicSignature),
        new TextEncoder().encode(message));
      if (!ok) throw new Error("bad signature");         // other agent, action, payload or lineage
    }
    // High-value step? Also call verifyDecision: a local check cannot see a revocation.

decideActiongetApprovalStatusreportExecutionOutcomegetGrantVerificationKeyverifyDecision

Traps to avoid

Facts the agent wrote

Context facts come from workspace settings, your own records or a validated identity token. The agent's own output never becomes a fact.

Values that change between asks

A timestamp or random id in the payload or context turns the claim into a new request, and the approval no longer applies. Freeze the request when you park it; only a provenance fact's observedAt may be refreshed.

Asking again to check on a person

Poll getApprovalStatus while a step is held. Call decideAction once more only when it reads granted, within fifteen minutes.

Hashing something other than what runs

Fingerprint the exact content the step will touch. If it can change after you ask, recompute and compare before acting.

Skipping the report

Report executed, aborted or downstream_error after every granted step, so the decision record shows what actually happened.

Retrying after a lost connection

Send an Idempotency-Key unique to the step attempt, such as run id plus step number, so a repeat returns the same answer instead of asking twice. That 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, so a retry never does the work twice.

Explore further

Open-source companions

Supported, Apache 2.0 packages that run in your own process. They do the repeatable work of the gate — fingerprinting the work locally, asking, waiting for a person, checking the grant token against the published key set, failing closed and reporting the outcome — so you supply only the action name and the context facts your rules need.

  • @complyee/mcp-guard — TypeScript MCP servers

    npm install @complyee/mcp-guard

    npm →GitHub →

  • complyee-agent-guard — Python agents and orchestrators (LangGraph, CrewAI, LangChain)

    pip install complyee-agent-guard

    PyPI →GitHub →