Integrations CrewAI
crewai 0.80+

Wrap a deny.sh vault entry as a CrewAI tool.

CrewAI exposes credentials to agents through the @tool decorator and the BaseTool subclass — both attach to an Agent via its tools list, and the Crew orchestrates task execution with crew.kickoff(). The deniable pattern resolves the credential inside the tool function, makes the privileged HTTPS call, and returns only a narrowed DTO to the agent. The key never enters the LLM context at any point in the crew loop.

Jump to code ↓

Prerequisites

Python: @tool decorator (primary path)

Show code — ~30 lines, python
# pip install crewai deny-sh requests
import os, requests, json
from crewai import Agent, Task, Crew
from crewai.tools import tool
from deny_sh import vault_get

@tool("Get Stripe Invoice")
def get_invoice(id: str) -> str:
    """Look up a Stripe invoice by id and return its amount_due and status."""
    # resolve the credential INSIDE the tool boundary
    stripe_key = vault_get("stripe-prod", os.environ["VAULT_PW"])
    r = requests.get(f"https://api.stripe.com/v1/invoices/{id}",
        headers={"Authorization": f"Bearer {stripe_key}"})
    body = r.json()
    # narrowed DTO: never return raw upstream response bodies to the agent
    return json.dumps({
        "id": body.get("id"),
        "amount_due": body.get("amount_due"),
        "status": body.get("status"),
    })

billing_agent = Agent(
    role="Billing Assistant",
    goal="Look up invoice details accurately.",
    backstory="You are a billing assistant with read-only access to Stripe invoices.",
    tools=[get_invoice],
)
lookup_task = Task(
    description="Look up invoice in_1Abc123XYZ and summarise it.",
    expected_output="A concise summary of the invoice amount and status.",
    agent=billing_agent,
)
crew = Crew(agents=[billing_agent], tasks=[lookup_task])
result = crew.kickoff()
print(result)

Why this is the deniable shape

The Stripe key is resolved and consumed entirely inside get_invoice's scope. CrewAI's agent loop sees the tool name, its docstring description, and the narrowed return string. The underlying LLM provider sees exactly the same. The key never appears in the agent's goal, backstory, any task description, or any tool output. A prompt-injection that tries to exfiltrate STRIPE_KEY fails because the key was never in the model's context window.

Advanced variant: BaseTool subclass

Use BaseTool when you need typed args_schema validation, shared state across calls (e.g. a cached HTTP session), or finer control over how CrewAI serialises the tool definition. The deniable pattern is identical: credential resolved in _run, narrowed DTO returned.

Show code — ~30 lines, python
# pip install crewai deny-sh requests pydantic
import os, requests, json
from typing import Type
from pydantic import BaseModel, Field
from crewai import Agent, Task, Crew
from crewai.tools import BaseTool
from deny_sh import vault_get

class InvoiceInput(BaseModel):
    id: str = Field(description="The Stripe invoice ID to look up")

class GetInvoiceTool(BaseTool):
    name: str = "get_invoice"
    description: str = (
        "Look up a Stripe invoice by id and return its amount_due and status."
    )
    args_schema: Type[BaseModel] = InvoiceInput

    def _run(self, id: str) -> str:
        # resolve the credential INSIDE the tool boundary
        stripe_key = vault_get("stripe-prod", os.environ["VAULT_PW"])
        r = requests.get(f"https://api.stripe.com/v1/invoices/{id}",
            headers={"Authorization": f"Bearer {stripe_key}"})
        body = r.json()
        # narrowed DTO: only the fields the agent needs
        return json.dumps({
            "id": body.get("id"),
            "amount_due": body.get("amount_due"),
            "status": body.get("status"),
        })

billing_agent = Agent(
    role="Billing Assistant",
    goal="Look up invoice details accurately.",
    backstory="You are a billing assistant with read-only access to Stripe invoices.",
    tools=[GetInvoiceTool()],
)
lookup_task = Task(
    description="Look up invoice in_1Abc123XYZ and summarise it.",
    expected_output="A concise summary of the invoice amount and status.",
    agent=billing_agent,
)
crew = Crew(agents=[billing_agent], tasks=[lookup_task])
result = crew.kickoff()
print(result)

Multi-tenant production pattern

For agent platforms serving multiple customers, build tenant-scoped tools with a factory that closes over each tenant's vault password and deny.sh API key. Construct a fresh Crew per request so no shared state can bleed across tenant boundaries.

Show code — ~18 lines, python
def tools_for_tenant(tenant_pw: str, tenant_api_key: str):
    @tool("Get Stripe Invoice")
    def get_invoice(id: str) -> str:
        """Look up a Stripe invoice by id."""
        # tenant_pw and tenant_api_key captured in closure — no shared key
        stripe_key = vault_get("stripe-prod", tenant_pw,
            api_key=tenant_api_key)
        r = requests.get(f"https://api.stripe.com/v1/invoices/{id}",
            headers={"Authorization": f"Bearer {stripe_key}"})
        body = r.json()
        return json.dumps({"id": body.get("id"),
            "amount_due": body.get("amount_due"),
            "status": body.get("status")})
    return [get_invoice]

# per-request: build agent + crew fresh with this tenant's tools
agent = Agent(role="Billing Assistant", goal="Look up invoice details.",
    backstory="Expert billing agent.",
    tools=tools_for_tenant(tenant_pw, tenant_api_key))
task = Task(description="Look up invoice in_1Abc123XYZ.",
    expected_output="Invoice amount and status.", agent=agent)
result = Crew(agents=[agent], tasks=[task]).kickoff()

Each tenant's vault entries are cryptographically isolated: a leak of one tenant's vault password compromises only that tenant's secrets. The closure pattern ensures no fallback to a shared key is possible even if the factory is called without arguments.

Troubleshooting

The first five errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.

ModuleNotFoundError: No module named 'crewai.tools'

The crewai.tools subpackage — which exports both the @tool decorator and BaseTool — was introduced in CrewAI 0.60. Older installs (typically pulled in by a pinned transitive dependency) still use the legacy from crewai import tool path, which has a different signature.

Fix: pip install -U "crewai>=0.60". If a peer dependency forces an older pin, check pip show crewai | grep Version and align to 0.60+. In constrained environments, create a fresh virtualenv to avoid stale dist-info shadowing the upgrade.

Agent never calls the tool — loops and produces a hallucinated answer instead

CrewAI passes the tool's name and its docstring (or description field on BaseTool) to the LLM as the signal to invoke it. If the description is vague ("useful tool"), the model may reason around it rather than calling it — especially on smaller models.

Fix: write a description that names the exact use case and what the output contains: "Look up a Stripe invoice by id and return its amount_due and status." Also confirm the task description contains language that motivates the tool call — the task prompt and the tool description must be coherent. For debugging, set Crew(verbose=True) to see the full agent reasoning trace.

args_schema validation error — Pydantic v1 vs v2 conflict

CrewAI 0.60+ requires Pydantic v2. If your environment has pydantic==1.* installed (common in projects that pin older FastAPI or langchain versions), BaseTool.args_schema validation will fail with confusing errors like AttributeError: 'ModelMetaclass' object has no attribute 'model_fields'.

Fix: pip install -U "pydantic>=2.0". If you cannot upgrade globally, use a virtualenv or check whether your existing Pydantic v1 models need the pydantic.v1 compat shim. Do not mix pydantic.BaseModel from v2 with validator decorators from v1 — they are incompatible.

DENY_API_KEY or VAULT_PW not set at runtime

The deny.sh Python client reads DENY_API_KEY from the environment by default. In notebooks or local scripts, it's common to set env vars after the import, which misses the client initialisation. In production containers, the secret may be mounted only after the process starts.

Fix: set both DENY_API_KEY and VAULT_PW before any import deny_sh — or pass api_key= explicitly to vault_get. Never hard-code either value in source. Use python-dotenv locally and your platform's secret store (Fly secrets, Render env vars, AWS Secrets Manager) in production. Add both to .gitignore if using a .env file.

vault_get error: label_not_found when the tenant password is wrong

A wrong vault password returns label_not_found, not invalid_password. This is deliberate: the wire shape is identical so an attacker cannot enumerate which labels exist by probing passwords. The error looks like a missing entry even though the label is present under a different password.

Fix: confirm the tenant_pw matches the password used in the original vault_store call. If you have rotated the password, you must re-store every vault label under the new password — old ciphertexts will not decrypt with the new one. In multi-tenant setups, confirm the right tenant's password is flowing through the closure, not a default fallback.

Production checklist