How to require human approval before a CrewAI tool runs
CrewAI's tool hooks can ask for a yes in the terminal before a tool runs. This example routes a CrewAI refund tool through an Anlyon action instead: an approver decides outside the crew, Anlyon holds the Stripe key, a shared limit caps the day's refunds, and every refund returns a graded receipt.
To require approval before a CrewAI tool runs, you have two places to put the gate. CrewAI's own tool hooks pause the call in your process and ask in the terminal. Or the tool invokes an Anlyon action that requires approval, and the decision, the Stripe key and the call all sit outside the crew. This example does the second, and shows where the first still fits.
The crew is a support agent that refunds Stripe payments. Four things hold without relying on the model:
- Every refund waits for an approver. The request is held in Anlyon until someone decides it in the console or through the API.
- The crew never holds the Stripe key. It names an action. Anlyon attaches the key and calls Stripe.
- A shared limit caps the day's refunds. Every agent, session and key in the environment draws from the same allowance, reserved before dispatch.
- Every refund returns a receipt with a grade.
confirmedmeans Anlyon read the refund back from Stripe's own record.
Written against crewai 1.15 and anlyon 0.6 (Python). Stripe refunds run against your own Stripe account, with a live or a test key. This example uses a test key.
TL;DR
CrewAI PRE_TOOL_CALL hook | Anlyon action with approval | |
|---|---|---|
| Where the decision happens | In the crew's process, at the terminal | In Anlyon, from the console or the API |
| Who can decide | Whoever is at the terminal | An approver in your workspace, never the key that asked |
| Where the Stripe key lives | Wherever your tool reads it | The Anlyon vault, attached at dispatch |
| What runs after a yes | Your tool, with whatever input it now has | The exact request that was reviewed |
| Limit across agents and sessions | Not part of the hook | A shared limit in money, reserved before dispatch |
| What you get back | Your tool's return value | An invocation with a receipt grade |
What does CrewAI give you for tool approval?
As of 2026-10-18, CrewAI's tool call hooks run at two interception points, PRE_TOOL_CALL and POST_TOOL_CALL. A pre-call hook can change the tool's input in place or block the call by raising HookAborted. The hook context also exposes request_human_input(), which pauses the live console output and reads a reply from the terminal. CrewAI's docs show it as an approval gate:
from crewai.hooks import on, HookAborted, InterceptionPoint, ToolCallHookContext
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["refund_payment"])
def require_approval(ctx: ToolCallHookContext) -> None:
response = ctx.request_human_input(
prompt=f"Approve {ctx.tool_name}?",
default_message=f"Input: {ctx.tool_input}\nType 'yes' to approve:",
)
if response.lower() != "yes":
raise HookAborted(reason="denied by operator", source="approval-gate")
When a hook blocks a call, the tool does not run and the agent receives Tool execution blocked by hook. Tool: <name> as the result. The run continues.
That is a good fit for a crew someone is watching from a terminal. Three properties come with it, and they matter once the tool moves money:
- It runs in your process. As of 2026-10-18 the docs describe hooks as synchronous callables, and global hooks persist for the lifetime of the process. The decision exists only while that process is alive and waiting.
- It does not move a credential. If the tool calls Stripe with a key from the environment, the key is in the crew's process before and after the yes.
- A hook can change the input after review.
ctx.tool_inputis mutable, and so is every hook that runs after yours. What the person at the terminal saw is not bound to what the tool then sends.
CrewAI also has human_input on a Task. As of 2026-10-18 the docs describe it as a human reviewing the agent's final answer for the task, which is after the tools have run. It is not a tool gate.
How does the Anlyon version fit together?
CrewAI crew (holds an Anlyon key: invoke, read)
-> tool "refund_payment"
-> anlyon.actions.invoke("refund-payment", input, operation_key=...)
-> the request waits for an approver
<- the tool waits for the decision, then reads the invocation
-> on approval, Anlyon reserves the amount against the shared daily limit
-> Anlyon attaches the vaulted Stripe key and calls api.stripe.com
-> Anlyon reads the refund back from Stripe and grades the receipt
1. One-time setup, with an operator key
Run this from a deploy script, not from the crew. It stores the Stripe key, defines a refund action on the stripe.refund adapter, and sets a daily limit.
# setup.py
import os
from anlyon import Client
ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])
ops.secrets.put("STRIPE_KEY", value=os.environ["STRIPE_TEST_KEY"], allowed_hosts=["api.stripe.com"])
ops.actions.create(
name="refund-payment",
description="Refund part or all of a Stripe payment in USD. Amount is in cents.",
requires_approval=True,
adapter={
"type": "stripe.refund",
"config": {"account": "acct_1Nxyz", "mode": "test", "currency": "usd"},
"credentialSecret": "STRIPE_KEY",
},
)
# $500.00 a day, across every agent, session and key in this environment.
ops.impact_limits.create(
name="support-refunds-per-day",
dimension="money",
currency="usd",
period="day",
amount=50000,
action_name="refund-payment",
)
The adapter's mode must match the key: "test" with an sk_test_ key, "live" with an sk_live_ key. Creating a limit needs impact-limits:write, which no key gets by default, so give it to the operator key and not to the crew's. requires_approval=True gates every refund. To let small ones through, replace it with an approval policy later.
2. The tool
A CrewAI tool is a BaseTool with an args_schema and a _run method. This one invokes the action, waits for the decision, and tells the model in plain words what happened.
# refund_tool.py
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"])
# Fail at startup if the crew's key can do more than ask and read.
anlyon.auth.require_scopes(
["actions:invoke", "actions:read", "approvals:read"],
forbidden=["actions:write", "secrets:write", "approvals:decide"],
)
REPLY = {
"confirmed": "Refund issued. Anlyon read it back from Stripe's own record.",
"acknowledged": "Stripe accepted the refund. Nothing has read it back yet. Do not retry.",
"unknown": "No usable response from Stripe. Anlyon does not send it again. Do not retry.",
"failed": "Stripe rejected the refund. Report the error to the customer.",
"refused": "Anlyon refused the refund before sending it, for example because the daily limit is used up. Do not retry.",
"denied": "An approver denied the refund. Do not retry.",
"pending": "The refund is not finished yet. Do not retry.",
}
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.")
def settle(invocation_id: str) -> dict:
"""After a decision, Anlyon dispatches. Read the same invocation until it finishes."""
for _ in range(30):
invocation = anlyon.actions.invocation(invocation_id).data
if invocation["status"] not in ("pending_approval", "running"):
break
time.sleep(2)
return invocation
class RefundPaymentTool(BaseTool):
name: str = "refund_payment"
description: str = (
"Refund part or all of a Stripe payment in USD. Amount is in cents. "
"Every refund waits for an approver before it is sent."
)
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 f"The refund is waiting for an approver (approval {decided['id']}). Do not retry."
invocation = settle(invocation["id"])
grade = invocation.get("grade") or "pending"
return f"{REPLY[grade]} (invocation {invocation['id']})"
Three details carry the weight:
- The operation key comes from the order. If the model calls the tool twice for the same order, the second call returns the existing effect instead of a second refund. A different request under the same key is refused.
- The tool branches on the grade, not on the HTTP status. A denied approval grades
denied. An approval that expired gradesrefused. A refund the shared limit cannot cover gradesrefusedand is never sent. unknownis notfailed. If Stripe's response is lost, the refund may have happened. Anlyon recordsunknown, does not send it again, and settles it from Stripe's own record when a lookup finds the refund. The tool tells the model not to retry, because an agent that reads "error" will reasonably try again.
3. The crew
# crew.py
from crewai import Agent, Crew, Task
from refund_tool import RefundPaymentTool
support = Agent(
role="Support agent",
goal="Resolve refund requests accurately",
backstory="You handle order problems for an online shop. You report tool results exactly as they are returned.",
tools=[RefundPaymentTool()],
)
refund = Task(
description="Order {order_id} arrived broken. Refund payment {payment_intent} in full: {amount} cents.",
expected_output="One sentence saying what happened to the refund.",
agent=support,
)
crew = Crew(agents=[support], tasks=[refund])
print(crew.kickoff(inputs={"order_id": "ord_1042", "payment_intent": "pi_3Pabc", "amount": 24000}))
The tool parks on the approval. Open Approvals in the Anlyon console, review the refund, and approve it. Anlyon sends the request you reviewed, and the crew reports the grade.
Should you keep the CrewAI hook as well?
Use both when they answer different questions. The hook is a quick check with whoever is running the crew: "do you want the agent to try this at all?" The Anlyon approval is the decision on the production call itself: it is bound to the exact request, made by an approver who is not the key that asked, and it applies even if a code path skips the hook. That holds while no other copy of the Stripe key is in the crew's process.
If you cannot move a call into Anlyon yet, the SDK's approvals.gate() wrapper waits for an Anlyon decision and then runs your own function with your own credential. It is the local fallback. It does not move the key, and it cannot attest that the function ran.
What this does not cover
Anlyon governs the actions routed through its hosted executor. A CrewAI tool that calls Stripe directly with its own key is not governed by it, and neither is any other tool in the crew. The limit is shared within one environment, not across environments. A refund cannot be reversed through the Stripe API, so the approval is the point to stop one.
Related
- Stripe refunds within fixed limits: what the
stripe.refundadapter checks and what it cannot. - Shared spending limits for agents: why the allowance is reserved before dispatch.
- When an agent action times out: what happens to a refund whose response is lost.
- OpenAI Agents SDK example: the same pattern in TypeScript.
- Docs: CrewAI integration · Governed effects · Receipts and grades
Free during Early Beta Access, no credit card. Start building →
Frequently asked questions
How do I require human approval before a CrewAI tool runs?
As of 2026-10-18, CrewAI has tool call hooks. A hook registered with @on(InterceptionPoint.PRE_TOOL_CALL, tools=[...]) runs before the tool, can call ctx.request_human_input() to ask in the terminal, and raises HookAborted to block the call. For a decision made outside the crew's process, have the tool invoke an Anlyon action that requires approval. The tool waits for the decision and Anlyon makes the call.
Does Task(human_input=True) approve tool calls?
No. As of 2026-10-18, CrewAI documents human_input on a Task as having a human review the agent's final answer for that task. It is not a gate in front of an individual tool call. Use a PRE_TOOL_CALL hook or route the tool through an action that requires approval.
Does the crew hold the Stripe key in this example?
No. The key is in the Anlyon vault, bound to api.stripe.com, and the stripe.refund adapter attaches it at dispatch. The crew holds an Anlyon key that can invoke actions and read invocations and approvals. It cannot change the action or decide its own approval.
