---
title: "CrewAI: hosted tools and human approval"
description: "Call an Anlyon action from a CrewAI tool, wait for an approver outside the crew, keep provider credentials out of the crew's process, and report the receipt grade."
---

CrewAI runs the crew. Anlyon executes the named external action. As of 2026-10-18, a CrewAI `PRE_TOOL_CALL` hook runs in the crew's process: it can ask for a yes with `ctx.request_human_input()` and block the call by raising `HookAborted`, and it does not hold credentials. A hook's yes does not approve a pending Anlyon invocation. See CrewAI's [tool call hooks](https://docs.crewai.com/en/learn/tool-hooks).

## Prerequisites

Complete the [quickstart](/quickstart) in a test environment. Define a governed action, for example `refund-payment` on the `stripe.refund` adapter with `requires_approval=True`, using an operator credential outside the crew. See [Governed effects](/execution/governed-effects) for the adapter config and [Impact limits](/execution/impact-limits) to cap it.

The crew needs an Anlyon agent credential with `actions:invoke`, `actions:read` and `approvals:read`. It does not need the provider key or `approvals:decide`.

```bash
pip install crewai anlyon
```

## A tool that invokes the action

A CrewAI tool is a `BaseTool` with an `args_schema` and a `_run` method. `_run` invokes the action, waits for the decision, reads the same invocation until it finishes, and returns one plain sentence for the model.

```python
import os
import time
from typing import Type

from anlyon import Client
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

anlyon = Client(api_key=os.environ["ANLYON_AGENT_KEY"])
anlyon.auth.require_scopes(
    ["actions:invoke", "actions:read", "approvals:read"],
    forbidden=["actions:write", "secrets:write", "approvals:decide"],
)


class RefundInput(BaseModel):
    order_id: str = Field(..., description="Your order id. One refund per order.")
    payment_intent: str = Field(..., description="Stripe payment intent id, starting with pi_.")
    amount: int = Field(..., description="Amount to refund, in cents.")


class RefundPaymentTool(BaseTool):
    name: str = "refund_payment"
    description: str = "Refund a Stripe payment in USD. Amount is in cents. Every refund waits for an approver."
    args_schema: Type[BaseModel] = RefundInput

    def _run(self, order_id: str, payment_intent: str, amount: int) -> str:
        result = anlyon.actions.invoke(
            "refund-payment",
            {"paymentIntent": payment_intent, "amount": amount, "currency": "usd"},
            operation_key=f"refund:{order_id}",
        )
        invocation = result.data
        if invocation["pendingApproval"]:
            decided = anlyon.approvals.wait_for_decision(invocation["approvalId"], timeout_seconds=600)
            if decided["status"] == "pending":
                return "Waiting for an approver. Do not retry."
            for _ in range(30):
                invocation = anlyon.actions.invocation(invocation["id"]).data
                if invocation["status"] not in ("pending_approval", "running"):
                    break
                time.sleep(2)
        return f"Refund {invocation['id']}: grade {invocation.get('grade') or 'pending'}. Do not retry."
```

Pass the tool to an `Agent(tools=[RefundPaymentTool()])` as usual. Derive `operation_key` from your own business record, not from the model. The same key and request return the existing effect instead of a second refund.

## What the tool should tell the model

| Grade | Tool behavior |
| --- | --- |
| `confirmed` | Report success. A read-back matched the request |
| `acknowledged` | Report that the provider accepted it. Do not retry |
| `unknown` | No usable response. Anlyon does not send it again. Do not retry; an operator settles it |
| `failed` | Report the provider's rejection |
| `refused` | Stop. Anlyon refused it at dispatch, for example on an exhausted limit or an expired approval |
| `denied` | Stop. Do not resubmit to bypass the decision |
| `pending` | Report that it is not finished. Read the same invocation later |

## Hooks and Anlyon approvals together

A `PRE_TOOL_CALL` hook is useful as a check with whoever is running the crew. The Anlyon approval is the decision on the production call: bound to the exact request, decided by a credential other than the one that asked. Keep provider keys out of the crew's environment, or a tool can call the provider directly and skip both. `Task(human_input=True)` reviews the task's final answer, not a tool call.

A CrewAI tool that calls a provider directly is not routed through Anlyon and is not governed by it. For a call you cannot move yet, [`approvals.gate()`](/guides/approval-gates) waits for a decision and then runs your own function with your own credential.

See the [full CrewAI example](https://anlyon.com/examples/crewai-human-approval-tool-execution), [receipts and grades](/execution/outcomes), and the [Python SDK](/guides/python-sdk).
