Framework guides

How to add human approvals to Strands Agents

Add Contro1 approval gates, Control Map routing previews, signed callbacks, and full autonomous tool-call logging to Strands Agents running on Bedrock, AgentCore, or custom infrastructure.

Strands gives agents tools, hooks, interventions, Bedrock support, and AgentCore deployment paths. Contro1 adds the operational layer: which actions ran autonomously, which actions needed approval, who decided, and what evidence proves it.

Use the integration skill

Copy this skill link into your code agent to add Strands Agents and Contro1 to your system.

Copy skill link

Key takeaways

  • Start with the simple one-operator approval path: create a Contro1 request before one risky tool executes.
  • Use AfterToolCallEvent to show every autonomous Strands tool call in Contro1 without pausing the agent.
  • Use Control Map only when high-risk routing, quorum, SLA, fallback reviewers, or separation of duties matter.
  • Keep BedrockModel, Bedrock Guardrails, AgentCore Runtime, memory, and observability unchanged; Contro1 wraps actions and records decisions.

What this integration does

Contro1 sits around Strands tool execution. Low-risk tools can continue to run autonomously and be logged as audit records. Risky tools create an approval request before they act, then continue only after a signed human decision.

This works whether your Strands app uses Amazon Bedrock, Bedrock Guardrails, AgentCore Runtime, local development, or a custom container. Keep the Strands and Bedrock setup you already have; add Contro1 where tools cross into real-world action.

  • Show the whole Strands run: searches, reads, approvals, rejected actions, executed actions, and follow-up logs.
  • Route risky actions to the right human by role, priority, SLA, and optional quorum policy.
  • Export evidence for one request or review the whole case timeline by correlation_id or thread_id.

Install and configure

Install the Contro1 SDK next to Strands. Python is the most common path for Strands today, but TypeScript projects can use @contro1/sdk with the same request, webhook, and audit-record concepts.

requirements.txt
pip install strands-agents centcom
package.json
npm install @strands-agents/sdk @contro1/sdk
.env
CENTCOM_API_KEY=cc_live_your_key
CENTCOM_BASE_URL=https://api.contro1.com/api/centcom/v1
CENTCOM_WEBHOOK_SECRET=whsec_your_signing_secret
CENTCOM_CALLBACK_URL=https://your-app.example.com/webhooks/contro1
CENTCOM_AGENT_ID=agt_your_registered_agent

Give this skill to your coding agent

If you want a coding agent to wire this into an existing Strands project, give it the Strands integration skill. The skill tells the coding agent how to find Agent(...), tools, hooks, interventions, Bedrock configuration, and deployment entrypoints before it edits anything.

The skill also tells the coding agent to implement the simple approval path first, add audit logging for autonomous tool calls, and only add Control Map when the workflow actually needs routing or quorum certainty.

Skill link
https://github.com/contro1-hq/centcom-strands/raw/main/skills/centcom-strands.md

Open the Strands skill on GitHub

Simple approval before a risky tool

Start here. If one operator or one simple role can approve the action, you do not need Control Map first. This example gives the Strands agent WRITE capability, but pauses before the production write. The reviewer sees the target service, environment, requested change, reason, and enough context to approve or reject before anything is written.

In production, replace polling with the signed webhook pattern below or store the pending request in your own durable job state.

tools/write_production_config.py
import os
from centcom import CentcomClient
from strands import tool

client = CentcomClient(api_key=os.environ["CENTCOM_API_KEY"])

@tool
def write_production_config(service: str, key: str, value: str, reason: str) -> dict:
    run_id = os.getenv("STRANDS_RUN_ID", f"prod-write:{service}:{key}")
    request = client.create_protocol_request({
        "title": f"Approve production WRITE to {service}?",
        "request_type": "approval",
        "correlation_id": run_id,
        "external_request_id": f"strands:{run_id}:write_production_config",
        "source": {"integration": "strands", "framework": "strands-agents", "run_id": run_id},
        "routing": {"required_role": "production-operator", "priority": "urgent"},
        "actor": {"agent_id": os.getenv("CENTCOM_AGENT_ID", ""), "agent_name": "Strands production agent"},
        "context": {
            "tool_name": "write_production_config",
            "tool_input": {"service": service, "key": key, "value_preview": value[:200]},
            "action_type": "production_write",
            "environment": "production",
            "target": f"service:{service}",
            "requested_write": {
                "operation": "update_config",
                "service": service,
                "key": key,
                "value_preview": value[:200],
            },
            "summary": reason,
        },
        "continuation": {"mode": "decision", "webhook_url": os.environ["CENTCOM_CALLBACK_URL"]},
    })

    decision = client.wait_for_protocol_response(request["id"], timeout=600)
    if decision["status"] != "approved":
        raise PermissionError(decision.get("message") or "Production WRITE rejected by operator")

    result = production_api.update_config(service=service, key=key, value=value)
    client.log_action(
        action="strands.production_write_completed",
        summary=f"Wrote {key} to production service {service} after approval",
        source={"integration": "strands", "workflow_id": "production-agent", "run_id": run_id},
        outcome="success",
        correlation_id=run_id,
        in_reply_to={"type": "request", "id": request["id"]},
    )
    return result

Show every Strands agent action in Contro1

Approvals show reviewed actions. Audit records show autonomous actions. Use Strands AfterToolCallEvent to log every tool call that should appear in the Contro1 timeline without pausing the agent.

Use one run id as correlation_id across every audit record and request. That gives the customer one timeline: the agent searched, read data, asked for approval, received a decision, executed the action, and logged the final outcome.

hooks/log_all_tool_calls.py
import os
from centcom import CentcomClient
from strands import Agent
from strands.hooks import AfterToolCallEvent

client = CentcomClient(api_key=os.environ["CENTCOM_API_KEY"])

def log_tool_call(event: AfterToolCallEvent) -> None:
    tool_name = event.tool_use["name"]
    run_id = event.invocation_state.get("run_id", os.getenv("STRANDS_RUN_ID", "strands-run"))
    outcome = "failure" if isinstance(event.result, Exception) else "success"

    client.log_action(
        action=f"strands.tool.{tool_name}",
        summary=f"Strands tool completed: {tool_name}",
        source={"integration": "strands", "workflow_id": "agent", "run_id": run_id},
        outcome=outcome,
        severity="warning" if outcome == "failure" else "info",
        correlation_id=run_id,
        external_request_id=f"strands:{run_id}:{tool_name}",
        metadata={
            "framework": "strands-agents",
            "tool_name": tool_name,
            "tool_input": event.tool_use.get("input", {}),
        },
    )

agent = Agent(tools=[search_docs, write_production_config], hooks=[log_tool_call])
hooks/logAllToolCalls.ts
import { Agent, AfterToolCallEvent } from '@strands-agents/sdk'
import { CentcomClient } from '@contro1/sdk'

const client = new CentcomClient({ apiKey: process.env.CENTCOM_API_KEY! })

const agent = new Agent({ tools: [searchDocs, sendCustomerEmail] })

agent.addHook(AfterToolCallEvent, async (event) => {
  const toolName = event.toolUse.name
  const runId = String(event.invocationState?.runId ?? process.env.STRANDS_RUN_ID ?? 'strands-run')

  await client.logAction({
    action: `strands.tool.${toolName}`,
    summary: `Strands tool completed: ${toolName}`,
    source: { integration: 'strands', workflow_id: 'agent', run_id: runId },
    outcome: event.result?.status === 'error' ? 'failure' : 'success',
    correlation_id: runId,
    external_request_id: `strands:${runId}:${toolName}`,
    metadata: {
      framework: 'strands-agents',
      tool_name: toolName,
      tool_input: event.toolUse.input,
    },
  })
})

Preview routing with Control Map

Control Map is not the approval flow itself. It is a routing preview. Use it before high-risk approvals, quorum approvals, strict role routing, SLA/fallback workflows, or production actions where waiting on an unroutable request would be a bad experience.

Do not use Control Map for every low-risk read, search, list, classify, or summarize tool call. Those are usually audit-only records.

  • If satisfiable is true, create the real approval request for the action.
  • If status is needs_mapping or needs_capacity, fail closed and surface warnings or suggested_action.
  • Cache positive previews briefly by role or policy when the same high-risk workflow runs often.
control-map.sh
contro1 requests control-map \
  --role finance \
  --required-approvals 2 \
  --approval-role finance \
  --must-include-role cfo \
  --risk high \
  --reason "Payment exceeds autonomous limit" \
  --format json
control_map.py
import os
import httpx

BASE_URL = os.getenv("CENTCOM_BASE_URL", "https://api.contro1.com/api/centcom/v1")

preview = httpx.post(
    f"{BASE_URL}/requests/control-map",
    headers={"Authorization": f"Bearer {os.environ['CENTCOM_API_KEY']}"},
    json={
        "type": "approval",
        "question": "Preview finance approval routing",
        "context": "High-risk Strands payment tool",
        "required_role": "finance",
        "risk_level": "high",
        "approval_requirements": {
            "required_roles": ["finance"],
            "required_approvals": 2,
            "must_include_roles": ["cfo"],
        },
        "approval_policy": {
            "mode": "threshold",
            "required_approvals": 2,
            "required_roles": ["finance", "cfo"],
            "separation_of_duties": True,
            "fail_closed_on_timeout": True,
        },
    },
).json()

if not preview.get("satisfiable"):
    raise RuntimeError(preview.get("suggested_action") or preview.get("warnings"))

Signed webhook handling

Production approval flows should verify Contro1 callbacks before resuming a delayed action. The callback is signed with your organization webhook secret and includes the request id.

Treat denied, cancelled, expired, invalid signature, stale timestamp, unknown request id, and mismatched pending action as fail-closed outcomes.

webhook_receiver.py
import os
from centcom import verify_webhook
from fastapi import FastAPI, Request, Response

app = FastAPI()
DECISIONS = {}

@app.post("/webhooks/contro1")
async def contro1_webhook(request: Request) -> Response:
    raw_body = await request.body()
    signature = request.headers.get("X-CentCom-Signature", "")
    timestamp = request.headers.get("X-CentCom-Timestamp", "")
    request_id = request.headers.get("X-CentCom-Request-Id", "")

    if not verify_webhook(raw_body, signature, timestamp, os.environ["CENTCOM_WEBHOOK_SECRET"]):
        return Response("invalid signature", status_code=401)

    payload = await request.json()
    response = payload.get("response") or {}
    DECISIONS[request_id] = {
        "approved": bool(response.get("approved")),
        "payload": payload,
    }
    return Response("ok", status_code=200)

Bedrock and AgentCore notes

Strands has first-class model provider support for Amazon Bedrock and deployment paths through Amazon Bedrock AgentCore. Keep those layers responsible for model access, runtime hosting, memory, guardrails, and observability.

Contro1 is the action control and evidence layer. Add it before tools that send, spend, update, delete, deploy, or change access, and after tools that should be visible as autonomous audit events.

LayerKeep using it forContro1 adds
StrandsAgent loop, tools, hooks, interventions, multi-agent patternsApproval and audit hooks around tools
Bedrock / AgentCoreModel provider, runtime, memory, guardrails, AWS operationsHuman decision workflow before real-world actions
Contro1Approvals, routing, escalation, signed callbacks, evidenceOne case timeline across autonomous and reviewed actions

CLI setup and evidence

Use the CLI to register the Strands agent, test routing, create a manual approval during development, and export evidence after a decision.

strands-cli.sh
contro1 auth login

contro1 agents register \
  --name "Production Strands Agent" \
  --type strands

contro1 requests create \
  --type approval \
  --question "Approve this production WRITE?" \
  --agent agt_123 \
  --role production-operator \
  --risk high \
  --reason "Strands agent wants WRITE access to update production config" \
  --wait

contro1 evidence for-request <request_id>

Contro1 CLI · Requests API · Webhooks

Send full agent traceability

Beyond the approval call, attach identity, a run trace, the tools you invoked, and the context you retrieved. Each field is optional — add what you have. The verified identity always comes from your API key; a caller-supplied actor.agent_id is recorded as a claimed sub-agent until an admin verifies it.

  • trace_id / parent_trace_id — link one run (and sub-agent runs) into a single trace.
  • tool_calls[] — what the agent tried to do, so reviewers see the actions.
  • retrieved_context[] — the data the decision was based on (RAG provenance).
  • Then export a signed evidence packet from GET /requests/:id/evidence.
Send full traceability
# Same approval call you already make — now with full traceability.
client.requests.create(
    request_type="approval",
    title="Refund $4,200 to customer 8831",
    source={"integration": "strands"},
    actor={"agent_id": "billing-agent", "agent_name": "Billing Agent"},  # claimed sub-agent
    trace_id=f"trc_{run_id}",          # link every step of this run
    tool_calls=[{"name": "lookup_order", "outcome": "success"}],
    retrieved_context=[{"source": "policy:refunds", "uri": "kb://policy/refunds"}],
    continuation={"mode": "decision"},
)

Agent identity, traceability & signed evidence

Frequently asked questions

Do I need Control Map for one operator?

No. Start with a normal approval request when one operator or one simple role can approve the action. Add Control Map when high-risk routing, quorum, SLA, fallback reviewers, or separation of duties matter.

Can Contro1 show autonomous Strands actions too?

Yes. Use AfterToolCallEvent to write audit records for autonomous tool calls. Audit records do not pause the agent; approval requests do.

What should correlation_id be?

Use the Strands run id, session id, or another stable execution id. Use the same value for approvals and audit records so Contro1 shows one timeline.

Does this replace Bedrock Guardrails or AgentCore?

No. Keep Bedrock and AgentCore for model/runtime infrastructure. Contro1 handles human decisions, routing, signed callbacks, audit records, and evidence around actions.

Should I use polling or webhooks?

Polling is fine for local demos and simple scripts. Production systems should verify signed webhooks or use a durable decision store before resuming work.

Can a coding agent implement this for me?

Yes. Give it the centcom-strands skill link from this page. The skill tells it how to inspect a Strands project, classify tools, add audit logging, add approvals, and use Control Map only when needed.