| title | Agent Control Specification Tutorial |
|---|---|
| last_reviewed | 2026-07-31 |
| owner | docs-team |
Time: 20 minutes · Level: Intermediate · Prerequisites: Python 3.11+, a repository checkout, and
opaonPATH
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.
!!! tip "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, anddeny
!!! important "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.
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.
From the repository root:
cd policy-engine
python -m pip install ./sdk/pythonThe 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.
mkdir -p /tmp/acs-email-tutorial/policy
cd /tmp/acs-email-tutorialCreate 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: internalThe 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 |
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 |
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.
python run.pyExpected 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.
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.
Change the manifest so tool_name_from points at a missing path:
tool_name_from: $.tool_call.missing_nameRun 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.
- Run the framework-neutral AGT host example at
examples/acs-email-tool. - Inspect the ACS plus ATR annotator example.
- Read the Agent Control Specification package page.
- Compare Rego and Cedar in OPA / Rego / Cedar Policies.
- Add human review with Approval Workflows.
- Review policy composition with Policy Composition.