Skip to content
Agentic Control Plane

Strands Agents + ACP — Policy & Audit Install Guide

Strands Agents is AWS’s open-source Python SDK for building agents — the framework behind Amazon Q Developer. It ships the best-documented hook seam in the framework category: typed lifecycle events around every tool call, including a hard cancel (cancel_tool) that AWS describes as rules the LLM cannot bypass.

That cancel is the hands. acp-governance is the brain: workspace policy, per-user identity, human approvals, and an off-machine audit trail — the same rules your Claude Code, LangChain, and CrewAI agents already follow, now enforced through Strands’ own hooks.

Starter · 5-minute install. pip install acp-governance, add one HookProvider, bind the user’s JWT per request. See the runnable starter or the frameworks index.

Install

pip install acp-governance strands-agents

Minimal governed agent

from strands import Agent, tool
from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent, HookProvider, HookRegistry
from acp_governance import configure, pre_tool_use, post_tool_output, set_context

configure(base_url="https://api.agenticcontrolplane.com")


class ACPHookProvider(HookProvider):
    def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
        registry.add_callback(BeforeToolCallEvent, self.before_tool)
        registry.add_callback(AfterToolCallEvent, self.after_tool)

    def before_tool(self, event: BeforeToolCallEvent) -> None:
        allowed, reason = pre_tool_use(event.tool_use["name"], event.tool_use.get("input") or {})
        if not allowed:
            event.cancel_tool = f"[ACP] Denied: {reason}"

    def after_tool(self, event: AfterToolCallEvent) -> None:
        if event.cancel_message is not None:
            return  # cancelled pre-execution; nothing ran
        verdict = post_tool_output(
            event.tool_use["name"], event.tool_use.get("input") or {},
            "".join(b.get("text", "") for b in event.result["content"]),
        )
        if verdict and verdict.get("action") == "redact":
            event.result = {"toolUseId": event.result["toolUseId"], "status": "success",
                            "content": [{"text": verdict["modified_output"]}]}
        elif verdict and verdict.get("action") == "block":
            event.result = {"toolUseId": event.result["toolUseId"], "status": "error",
                            "content": [{"text": f"[ACP] Blocked: {verdict.get('reason', '')}"}]}


@tool
def lookup_record(id: str) -> str:
    """Look up a record by ID."""
    return db.records.find_one({"id": id})


agent = Agent(tools=[lookup_record], hooks=[ACPHookProvider()])

set_context(user_token=user_jwt, agent_name="my-strands-agent", agent_tier="interactive")
agent("Look up record abc-123")

One provider covers every tool registered on the agent — no per-tool decorators, no wrapper functions.

What happens on every tool call

  1. BeforeToolCallEvent fires; the provider POSTs to ACP’s /govern/tool-use with the tool name, input, and the user JWT bound by set_context.
  2. Denyevent.cancel_tool is set. Strands skips the tool function entirely and synthesizes an error ToolResult carrying the reason. The model sees the denial and adapts. This is enforced in the executor, not the prompt — the model cannot argue its way past it.
  3. Allow → your function runs.
  4. AfterToolCallEvent fires; the output is reported to /govern/tool-output for audit + PII scan. If policy says redact or block, the provider rewrites event.result before it reaches the conversation. Audit row written, rooted in the end user’s identity.

Per-tier policy

set_context(agent_tier="...") selects the policy tier:

  • interactive — human at the keyboard, permissive default.
  • subagent — invoked by another agent, no human in the immediate loop.
  • background — autonomous, most restrictive.
  • api — programmatic call from your backend.

Strands-specific notes

  • Frozen events, writable seams. Strands hook events reject attribute writes except for the fields designed for intervention: cancel_tool, selected_tool, and tool_use on the before event; result and retry on the after event. The integration uses exactly those.
  • Denied calls still fire AfterToolCallEvent with cancel_message set — the provider skips output processing for them since nothing executed.
  • Model-agnostic. BedrockModel (default), AnthropicModel, OpenAIModel, OllamaModel, and the rest all run the same tool executor, so the same hooks cover all of them.
  • Multi-agent orchestration. Graph/swarm nodes have their own BeforeNodeCallEvent/AfterNodeCallEvent; each member agent carries its own hooks=[ACPHookProvider()], so tool coverage follows the agent, not the orchestrator.

Limitations

  • Tool-layer, not token-layer. LLM calls go direct to your provider with your own key; ACP checks and records tool calls.
  • Pre-release. acp-governance is on 0.x. A framework-specific acp-strands package with a packaged ACPHookProvider is planned.