Skip to content

Tutorial 55: Agent Control Specification

Time: 20 minutes · Level: Intermediate · Prerequisites: Python 3.11+, a repository checkout, and opa on PATH

What you will build

Build an ACS policy enforcement point for an email tool. The host sends ACS a complete snapshot before and after the tool call, then enforces the returned verdict.

Prefer a runnable repository example?

Start with examples/acs-email-tool in a repository checkout. It demonstrates the canonical Python host path through AgentControl, HostSession, and SnapshotBuilder without OPA. This tutorial adds a Rego policy to expose the native manifest and policy-input contract.

You will create:

  • a flat ACS manifest
  • a Rego policy bundle
  • a Python host that calls AgentControl.run_tool()
  • three outcomes: allow, transform, and deny

Public Preview

ACS is vendored into AGT under policy-engine/ as the AGT 5.0 policy layer. The APIs and manifest shape may change before GA.

How ACS fits in AGT

ACS makes the policy decision. Your application or adapter enforces it.

Host adapter -> snapshot -> ACS runtime -> verdict -> host enforcement

Each evaluation includes its full context: the intervention point, tool call, tool result, labels, and metadata. ACS retains no session state between calls.

Step 1: Install the Python SDK from the repo

From the repository root:

cd policy-engine
python -m pip install ./sdk/python

The agent-control-specification distribution builds the native Rust core with maturin when installed from source. It includes AgentControl, HostSession, and SnapshotBuilder for Python hosts.

OPA-backed Rego examples require the opa CLI on PATH.

Step 2: Create the tutorial workspace

mkdir -p /tmp/acs-email-tutorial/policy
cd /tmp/acs-email-tutorial

Step 3: Write the ACS manifest

Create manifest.yaml:

agent_control_specification_version: "0.3.1-beta"
metadata:
  name: acs-email-tutorial
policies:
  email_policy:
    type: rego
    bundle: ./policy
    query: data.agent_control_specification.email_policy.verdict
intervention_points:
  pre_tool_call:
    policy_target: $.tool_call.args
    policy_target_kind: tool_args
    tool_name_from: $.tool_call.name
    policy:
      id: email_policy
  post_tool_call:
    policy_target: $.tool_result.value
    policy_target_kind: tool_result
    tool_name_from: $.tool_call.name
    policy:
      id: email_policy
tools:
  send_email:
    type: Tool
    id: send_email
    clearance: internal

The manifest binds the same Rego policy at two intervention points:

Intervention point What ACS evaluates
pre_tool_call The outbound tool arguments before the email tool runs
post_tool_call The tool result before it returns to the caller

Step 4: Write the Rego policy

Create policy/email_policy.rego:

package agent_control_specification.email_policy

import rego.v1

default verdict := {"decision": "allow"}

verdict := {
  "decision": "deny",
  "reason": "external_recipient_blocked",
  "message": "Messages to external recipients are blocked."
} if {
  input.intervention_point == "pre_tool_call"
  input.tool.name == "send_email"
  endswith(input.policy_target.value.to, "@example.net")
}

verdict := {
  "decision": "transform",
  "reason": "redact_tracking_token",
  "message": "Tracking token redacted before tool execution.",
  "transform": {
    "path": "$policy_target.body",
    "value": "Your case is ready. Tracking token: [REDACTED]"
  }
} if {
  input.intervention_point == "pre_tool_call"
  input.tool.name == "send_email"
  contains(input.policy_target.value.body, "TRACK-")
}

The policy returns:

Input Verdict
Normal internal email allow
Internal email with a tracking token transform
External @example.net recipient deny

Step 5: Write the host

Create run.py:

import asyncio
from pathlib import Path

from agent_control_specification import AgentControl, AgentControlBlocked

ROOT = Path(__file__).parent


async def send_email(args):
    return {"sent": True, "to": args["to"], "body": args["body"]}


async def main():
    control = AgentControl.from_path(str(ROOT / "manifest.yaml"))

    allowed = await control.run_tool(
        "send_email",
        {"to": "customer@example.com", "body": "Your case is ready."},
        send_email,
        tool_call_id="email-1",
    )
    print(allowed.value)

    transformed = await control.run_tool(
        "send_email",
        {
            "to": "customer@example.com",
            "body": "Your case is ready. Tracking token: TRACK-123",
        },
        send_email,
        tool_call_id="email-2",
    )
    print(transformed.value)

    try:
        await control.run_tool(
            "send_email",
            {"to": "partner@example.net", "body": "Hello."},
            send_email,
            tool_call_id="email-3",
        )
    except AgentControlBlocked as exc:
        print(exc.result.verdict.reason)


asyncio.run(main())

run_tool() evaluates pre_tool_call before execution and post_tool_call after the tool returns.

Step 6: Run it

python run.py

Expected output:

{'sent': True, 'to': 'customer@example.com', 'body': 'Your case is ready.'}
{'sent': True, 'to': 'customer@example.com', 'body': 'Your case is ready. Tracking token: [REDACTED]'}
external_recipient_blocked

The second call applies the transform verdict before execution. The third blocks the tool after a deny verdict.

Step 7: Inspect the policy input shape

ACS policies evaluate a canonical policy input. For pre_tool_call, the Rego policy receives fields like:

{
  "intervention_point": "pre_tool_call",
  "policy_target": {
    "path": "$.tool_call.args",
    "kind": "tool_args",
    "value": {
      "to": "customer@example.com",
      "body": "Your case is ready."
    }
  },
  "tool": {
    "name": "send_email",
    "id": "send_email",
    "clearance": "internal"
  }
}

The input may include more snapshot, annotation, and manifest-derived fields. Read only the canonical fields the policy needs; do not depend on host-local state.

Step 8: Try a fail-closed case

Change the manifest so tool_name_from points at a missing path:

tool_name_from: $.tool_call.missing_name

Run the host again. ACS fails closed with a runtime error verdict instead of allowing the call.

Malformed manifests, missing paths, policy dispatcher failures, and invalid transform targets produce deny verdicts with reserved runtime-error reasons.

Next steps