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

Engine dependency

AGT uses the published agent-control-spec engine. This tutorial uses AGT's compatibility host SDK in policy-engine/, not the standalone upstream Python API.

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 AGT's native host binding with maturin. Its Rust dependencies include the published ACS engine. It includes AgentControl, HostSession, and SnapshotBuilder for Python hosts. The upstream agent-control-spec Python package has a different API and cannot be substituted in this tutorial.

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.4.0-alpha.1"
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": "$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. The manifest must bind both points. An unbound point denies with runtime_error:intervention_point_unknown, and if post_tool_call is missing, run_tool() raises that block before it calls the tool, so no side effect happens for a call it would report as blocked.

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