# CrewAI: Per-User Auth, Policy & Audit for Tools

Add per-user policy, rate limits, PII detection, and audit to CrewAI tool calls. Same control model as Claude Code. One pip install, one decorator.

# Per-user auth, policy & audit for CrewAI tools

**TL;DR.** `pip install acp-crewai`, wrap tools with `@governed`, bind the end user's JWT — and every CrewAI tool call gets per-user identity, policy enforcement (allow / deny / redact), rate limits, PII detection, and an audit trail. One decorator, same control model as Claude Code.

[CrewAI](https://docs.crewai.com) is a popular Python framework for building multi-agent systems. Out of the box, a CrewAI deployment shares one backend API key across every end user's request — no per-user policy, no per-user audit trail, no way to tell downstream systems which human triggered which action.

`acp-crewai` closes that gap. Wrap tools with `@governed`; before each runs, ACP decides allow / deny / redact based on your workspace's policy, the end user's identity, rate limits, and PII detection. Same control model as Claude Code — same `/govern/tool-use` endpoint, same workspace policies.

> **Starter · 5-minute install.** `pip install acp-crewai`, wrap tools with `@governed`, bind the end user's JWT per request, and optionally call `install_crew_hooks(crew)` to audit inter-agent handoffs. See [how a call is decided](/docs/governance-model) for the shared concepts across every framework, or the [frameworks index](/frameworks) for other options.

## Install



```bash
pip install acp-crewai
```

## Minimal policy-enforced crew

```python
from crewai import Agent, Crew, Task
from crewai.tools import tool
from fastapi import FastAPI, Header
from acp_crewai import governed, install_crew_hooks, set_context

app = FastAPI()

# Define tools however you want. Stack @governed under @tool — the
# governance check runs inside CrewAI's tool dispatch.
@tool("web_search")
@governed("web_search")
def web_search(query: str) -> str:
    """Search the web."""
    return my_search(query)   # your code, your credentials

@tool("send_email")
@governed("send_email")
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email on behalf of the user."""
    return sendmail(to, subject, body)

@app.post("/run")
def run(topic: str, authorization: str = Header(...)):
    # Bind the end user's JWT to this request's context.
    set_context(user_token=authorization.removeprefix("Bearer ").strip())

    researcher = Agent(role="researcher", goal=f"Research {topic}",
                       tools=[web_search, send_email])
    task = Task(description=f"Research {topic} and email me a summary.",
                agent=researcher)
    crew = Crew(agents=[researcher], tasks=[task])

    # Capture inter-agent handoffs too (sequential task passes, coworker
    # delegation) as synthetic Agent.Handoff audit events.
    install_crew_hooks(crew)

    return {"result": str(crew.kickoff())}
```

## What happens on every tool call

1. `@governed` POSTs `{ tool_name, tool_input, session_id }` + `Authorization: Bearer <user-jwt>` to ACP's `/govern/tool-use`.
2. ACP evaluates workspace policy, the user's scopes, rate limits, and PII.
3. **Deny** → the wrapped function returns `"tool_error: <reason>"`. CrewAI treats this as the tool's output; the model sees it and adapts.
4. **Allow** → your tool runs.
5. Post-audit: ACP scans the output for PII. If policy says `redact`, the redacted version replaces the tool's output. Audit row written, rooted in the end user's identity.

## Inter-agent handoffs

CrewAI has two delegation paths that don't cross a tool boundary, so `@governed` alone doesn't see them:

- **Sequential task handoffs** — Task N's output feeds Task N+1's context
- **Hierarchical delegation** — CrewAI's built-in "Delegate work to coworker" / "Ask question to coworker" tools

`install_crew_hooks(crew)` attaches `task_callback` and `step_callback` so inter-agent messages emit synthetic `Agent.Handoff` audit events. PII scanning applies; audit rows are written; existing callbacks are chained, not overwritten.

## Fail-open

If `/govern/tool-use` times out (5s default) or is unreachable, the tool proceeds. Matches Claude Code hook behavior. Control is never a single point of failure for the agent.

## Configure your ACP workspace

Before the crew can usefully call tools, your ACP workspace needs:

1. **An IdP configured** — ACP verifies the end user's JWT against your identity provider (Firebase, Auth0, any OIDC). Dashboard → Settings → Identity Provider.
2. **Tools listed** — the names in `@governed("...")` must match tools enabled in your workspace. Dashboard → Policies → Tools.
3. **Policy per tier** — set allow/deny, rate limits, PII mode per agent tier. Dashboard → Policies.

If the dev themselves is the end user (single-user test), Firebase login to `cloud.agenticcontrolplane.com` gives a valid JWT out of the box.

## What shows up in the dashboard

Every policy-checked call — tool or inter-agent handoff — appears in [cloud.agenticcontrolplane.com/activity](https://cloud.agenticcontrolplane.com/activity) with:

- **Actor** — the end user's sub (not your service account)
- **Tool name** — whatever you passed to `@governed(...)`
- **Decision** — allow / deny / redact, with reason
- **Session** — groups all tool calls from one request
- **Findings** — PII detected in input or output, if any

CrewAI tool calls sit alongside Claude Code and Cursor calls from the same user. One audit log across every agent surface.

## Add or replace tools

```python
from crewai.tools import tool
from acp_crewai import governed

@tool("my_tool")
@governed("my_tool")
def my_tool(arg: str) -> str:
    """Description the LLM sees."""
    # your code: your API, your credentials
    return "..."
```

`@tool` (CrewAI) and `@governed` (ACP) stack — `@governed` must sit closer to the function so the policy check runs inside CrewAI's tool dispatch. Tools outside this pattern are not controlled.

## Comparison: CrewAI native vs. ACP

CrewAI's own features and what `acp-crewai` adds:

| Concern | CrewAI native | `acp-crewai` |
|---|---|---|
| Workflow (agents, tasks, crew composition) | ✅ | — |
| Workspace-level RBAC (who can edit crews) | ✅ (Enterprise) | — |
| Tracing / observability (what ran, latency) | ✅ (Enterprise) | Audit is narrower but enforced |
| Per-tool-call policy enforcement | ❌ | ✅ |
| Per-end-user rate limits / budgets | ❌ | ✅ |
| PII detection on tool I/O | ❌ | ✅ |
| Per-end-user audit trail across tool calls | ❌ (workspace-level) | ✅ (per human) |
| Cross-framework unified audit (Claude Code + CrewAI + ...) | ❌ (framework-specific) | ✅ |

Complementary, not duplicative. Use CrewAI Enterprise for what it does; add `acp-crewai` where CrewAI doesn't reach.

## Price and meter the model calls

`@governed` is the **interception plane** — what your agent *does*. The **proxy
plane** covers what it *spends*. Both run against the same gateway, and `init()`
wires them together:

```python
import acp_governance as acp

acp.init()            # call before you construct any model client
```

Model calls now land on the same trail as tool calls, carrying real cost,
tokens, and cache economics. Constructing clients explicitly instead? Pass the
config rather than calling `init()`:

```python
from acp_governance import model_client_kwargs
from anthropic import Anthropic

client = Anthropic(**model_client_kwargs("anthropic"))
```

Shapes are `"anthropic"`, `"openai"` (chat completions), and
`"openai-responses"`. The last two are **not** interchangeable — `/v1` serves
chat completions, `/openai/v1` serves responses.

`init()` is all-or-nothing per provider: it sets the base URL *and* the key, or
it leaves that provider completely alone and tells you which one it skipped. If
`OPENAI_BASE_URL` already points at your own gateway, ACP won't silently
reroute you.

**Your coverage:** interception ✓ (decorated tools) · proxy ✓ (model calls).

## Limitations

- **Only tools routed through `@governed` are covered.** If you write a plain Python function as a CrewAI tool and don't decorate it, ACP doesn't see the call. This is intentional — the decorator makes control an explicit choice — but worth flagging.
- **The two planes are wired separately.** The decorator covers tool calls; the proxy covers model calls. Both ship, but each is its own edit — see [Price and meter the model calls](#price-and-meter-the-model-calls) above. Decorating tools without repointing the model client gives you control with no cost data.
- **Pre-release.** `acp-crewai@0.1.x` is the initial release. Pin exact versions.

## Troubleshooting

**Crew runs but nothing appears in the dashboard.** Confirm the end user's JWT is being sent as `Authorization: Bearer ...` and that ACP has the IdP configured for its issuer. Check the Activity log's workspace dropdown.

**`401` from `/govern/tool-use`.** The JWT is invalid, expired, or signed by an IdP ACP doesn't trust. Check Settings → Identity Provider in the dashboard.

**Tools run but decisions always show `allow` with reason `fail-open`.** The gateway request is erroring. Check network (is `api.agenticcontrolplane.com` reachable from your runtime?). Raise timeout via `configure(timeout_s=10)` if latency is the issue.

**Policy says deny, but the tool still runs.** Verify the tool is wrapped with `@governed` (not just `@tool`). Inspect the decorator order — `@governed` must be closer to the function than `@tool`.

## Related

- [`acp-crewai` on PyPI](https://pypi.org/project/acp-crewai)
- [`acp-governance` (core SDK)](https://pypi.org/project/acp-governance)
- [LangChain / LangGraph integration](/integrations/langgraph)
- [Deep Agents integration](/integrations/deepagents)
- [Anthropic Agent SDK integration](/integrations/anthropic-agent-sdk)
- [Claude Code integration](/integrations/claude-code)
- [Tool policies and scopes](/docs/policies)

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "HowTo",
  "name": "Govern CrewAI with Agentic Control Plane",
  "totalTime": "PT5M",
  "step": [
    {"@type": "HowToStep", "name": "Install acp-crewai", "text": "pip install acp-crewai"},
    {"@type": "HowToStep", "name": "Wrap tools with @governed", "text": "Stack @governed under @tool on each tool function."},
    {"@type": "HowToStep", "name": "Bind the user's JWT per request", "text": "Call set_context(user_token=...) at the start of each request."},
    {"@type": "HowToStep", "name": "(Optional) capture inter-agent handoffs", "text": "Call install_crew_hooks(crew) after building the Crew to audit inter-agent messages."}
  ]
}
</script>

