n4nAI

Integrating CrewAI agents with a REST API tool

Build a CrewAI custom tool that calls a REST API, with authentication, error handling, and structured output parsing for production use.

n4n Team3 min read737 words

Audio narration

Coming soon — every post will get a voice note here.

CrewAI agents become genuinely useful when they can reach outside their context window. A REST API tool lets agents query live data, trigger workflows, or mutate state — turning a chat loop into an automation engine. This guide walks through building a production-ready CrewAI custom tool that wraps any REST endpoint, handles auth and retries, and returns structured data your agents can reason over.

Step 1: Define the tool interface and data contracts

Start by deciding what the tool exposes to the agent. CrewAI tools inherit from BaseTool and must implement _run (sync) or _arun (async). The agent sees the class docstring and the _run signature, so type hints and clear descriptions directly affect tool selection accuracy.

Create a Pydantic model for the tool input. This validates arguments before the HTTP call and gives the LLM a schema to follow.

# tools/rest_tool.py
from pydantic import BaseModel, Field, HttpUrl
from typing import Optional, Literal, Dict, Any
from enum import Enum


class HttpMethod(str, Enum):
    GET = "GET"
    POST = "POST"
    PUT = "PUT"
    PATCH = "PATCH"
    DELETE = "DELETE"


class RestApiInput(BaseModel):
    """Arguments the agent must provide to call the REST API."""
    url: HttpUrl = Field(..., description="Full endpoint URL including query params")
    method: HttpMethod = Field(default=HttpMethod.GET, description="HTTP verb")
    headers: Optional[Dict[str, str]] = Field(
        default=None, description="Additional headers (auth added automatically)"
    )
    json_body: Optional[Dict[str, Any]] = Field(
        default=None, description="JSON payload for POST/PUT/PATCH"
    )
    params: Optional[Dict[str, Any]] = Field(
        default=None, description="Query string parameters"
    )
    timeout_seconds: int = Field(default=30, ge=1, le=120)

The output model matters just as much. Agents struggle with raw JSON strings. Return a typed object with the fields the agent actually needs.

class RestApiOutput(BaseModel):
    """Structured result the agent can reference in subsequent steps."""
    status_code: int
    success: bool
    data: Optional[Dict[str, Any]] = None
    error: Optional[str] = None
    response_headers: Dict[str, str] = Field(default_factory=dict)
    elapsed_ms: int

Step 2: Implement the tool with auth, retries, and observability

The tool should handle the concerns every HTTP client needs: authentication, timeout enforcement, retry logic for transient failures, and structured logging. Keep credentials out of the agent’s context — inject them at tool initialization.

# tools/rest_tool.py (continued)
import time
import logging
from typing import Type
from crewai.tools import BaseTool
import httpx
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential_jitter,
    retry_if_exception_type,
)

logger = logging.getLogger(__name__)


class RestApiTool(BaseTool):
    name: str = "rest_api_client"
        "Call a REST API endpoint. Provide the full URL, HTTP method, optional headers, "
        "query params, and JSON body. Returns structured response with status, data, and timing. "
        "Authentication headers are injected automatically from tool configuration."
    )
    args_schema: Type[BaseModel] = RestApiInput

    def __init__(
        self,
        base_headers: Optional[Dict[str, str]] = None,
        default_timeout: int = 30,
        max_retries: int = 3,
        **kwargs,
    ):
        super().__init__(**kwargs)
        self._base_headers = base_headers or {}
        self._default_timeout = default_timeout
        self._max_retries = max_retries
        self._client: Optional[httpx.Client] = None

    @property
    def client(self) -> httpx.Client:
        if self._client is None:
            self._client = httpx.Client(
                timeout=httpx.Timeout(self._default_timeout),
                limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
            )
        return self._client

    def _merge_headers(self, extra: Optional[Dict[str, str]]) -> Dict[str, str]:
        merged = {**self._base_headers}
        if extra:
            merged.update(extra)
        return merged

    @retry(
        wait=wait_exponential_jitter(initial=0.5, max=4),
        stop=stop_after_attempt(3),
        retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError, httpx.RemoteProtocolError)),
        reraise=True,
    )
    def _request_with_retry(
        self,
        method: str,
        url: str,
        headers: Dict[str, str],
        json_body: Optional[Dict],
        params: Optional[Dict],
        timeout: int,
    ) -> httpx.Response:
        return self.client.request(
            method=method,
            url=url,
            headers=headers,
            json=json_body,
            params=params,
            timeout=timeout,
        )

    def _run(
        self,
        url: str,
        method: HttpMethod = HttpMethod.GET,
        headers: Optional[Dict[str, str]] = None,
        json_body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
        timeout_seconds: int = 30,
    ) -> RestApiOutput:
        start = time.perf_counter()
        request_headers = self._merge_headers(headers)

        logger.info("REST tool request", extra={"method": method.value, "url": url})

        try:
            response = self._request_with_retry(
                method=method.value,
                url=url,
                headers=request_headers,
                json_body=json_body,
                params=params,
                timeout=timeout_seconds,
            )
            elapsed_ms = int((time.perf_counter() - start) * 1000)

            response_headers = dict(response.headers)
            try:
                data = response.json() if response.content else None
            except Exception:
                data = {"raw_text": response.text[:2000]}

            output = RestApiOutput(
                status_code=response.status_code,
                success=200 <= response.status_code < 300,
                data=data,
                error=None if response.is_success else f"HTTP {response.status_code}: {response.text[:500]}",
                response_headers=response_headers,
                elapsed_ms=elapsed_ms,
            )

            logger.info(
                "REST tool response",
                extra={"status": response.status_code, "elapsed_ms": elapsed_ms, "success": output.success},
            )
            return output

        except httpx.TimeoutException as e:
            elapsed_ms = int((time.perf_counter() - start) * 1000)
            logger.warning("REST tool timeout", extra={"url": url, "elapsed_ms": elapsed_ms})
            return RestApiOutput(
                status_code=408,
                success=False,
                error=f"Request timed out after {timeout_seconds}s",
                elapsed_ms=elapsed_ms,
            )
        except httpx.HTTPStatusError as e:
            elapsed_ms = int((time.perf_counter() - start) * 1000)
            return RestApiOutput(
                status_code=e.response.status_code,
                success=False,
                error=f"HTTP error: {e.response.text[:500]}",
                elapsed_ms=elapsed_ms,
            )
        except Exception as e:
            elapsed_ms = int((time.perf_counter() - start) * 1000)
            logger.exception("REST tool unexpected error")
            return RestApiOutput(
                status_code=500,
                success=False,
                error=f"Unexpected error: {type(e).__name__}: {str(e)[:200]}",
                elapsed_ms=elapsed_ms,
            )

Key design choices here:

  • Tenacity retries only on network-level failures, not 4xx/5xx responses. The agent should decide how to handle application errors.
  • Structured logging with extra fields makes it trivial to query in Datadog, Loki, or CloudWatch.
  • Header merging lets you inject Authorization, X-Request-ID, or User-Agent at tool construction while allowing per-call overrides.
  • httpx.Client reuse avoids connection overhead and respects keep-alive limits.

Step 3: Wire the tool into a CrewAI agent

Instantiate the tool with your auth configuration, then pass it to the agent’s tools list. The agent will see the docstring and schema automatically.

# agents/research_agent.py
from crewai import Agent, Task, Crew, Process
from tools.rest_tool import RestApiTool
import os


def build_github_research_agent() -> Agent:
    github_token = os.getenv("GITHUB_TOKEN")
    if not github_token:
        raise RuntimeError("GITHUB_TOKEN not set")

    github_tool = RestApiTool(
        base_headers={
            "Authorization": f"Bearer {github_token}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
            "User-Agent": "crewai-research-agent/1.0",
        },
        default_timeout=20,
        max_retries=3,
    )

    return Agent(
        role="GitHub Repository Analyst",
        goal="Answer questions about GitHub repositories using the REST API",
        backstory=(
            "You fetch live repository data, issues, PRs, and commit history "
            "to answer technical questions. You always cite the API endpoints used."
        ),
        tools=[github_tool],
        verbose=True,
        allow_delegation=False,
    )

Step 4: Design tasks that guide the agent’s tool usage

Agents need explicit guidance on when and how to use the tool. The task description should specify the endpoint patterns, expected response shapes, and how to chain calls.

# tasks/github_tasks.py
from crewai import Task
from agents.research_agent import build_github_research_agent


def create_repo_analysis_task(repo_owner: str, repo_name: str, question: str) -> Task:
    agent = build_github_research_agent()

    return Task(
        description=(
            f"Analyze the GitHub repository {repo_owner}/{repo_name} to answer: {question}\n\n"
            "Available API endpoints (use the rest_api_client tool):\n"
            f"- GET https://api.github.com/repos/{repo_owner}/{repo_name} — repo metadata\n"
            f"- GET https://api.github.com/repos/{repo_owner}/{repo_name}/issues — open issues\n"
            f"- GET https://api.github.com/repos/{repo_owner}/{repo_name}/pulls — pull requests\n"
            f"- GET https://api.github.com/repos/{repo_owner}/{repo_name}/commits — recent commits\n"
            f"- GET https://api.github.com/repos/{repo_owner}/{repo_name}/contents/{{path}} — file contents\n\n"
            "Process:\n"
            "1. Start with the repo metadata endpoint to verify access and get default branch.\n"
            "2. Fetch relevant issues, PRs, or commits based on the question.\n"
            "3. If the question references specific files, use the contents endpoint.\n"
            "4. Synthesize findings into a concise answer with endpoint citations.\n"
            "5. If rate limited (403 with 'rate limit' in response), wait and retry once."
        ),
        expected_output=(
            "A markdown report with:\n"
            "- Direct answer to the question\n"
            "- Key findings with API endpoint references\n"
            "- Any limitations or missing data\n"
            "- Raw data summary (counts, dates, statuses)"
        ),
        agent=agent,
    )

Step 5: Run the crew and verify the output

Execute the crew and inspect the structured tool outputs. The RestApiOutput model gives you programmatic access to status codes, parsed JSON, and timing — useful for downstream logic or evals.

# main.py
import json
import os
from dotenv import load_dotenv
from tasks.github_tasks import create_repo_analysis_task


def main():
    load_dotenv()

    task = create_repo_analysis_task(
        repo_owner="crewAIInc",
        repo_name="crewAI",
        question="What are the top 3 most recent merged PRs and what files did they modify?",
    )

    crew = Crew(
        agents=[task.agent],
        tasks=[task],
        process=Process.sequential,
        verbose=True,
    )

    result = crew.kickoff()
    print("\n=== FINAL RESULT ===")
    print(result)

    # For programmatic access to tool outputs, inspect task.output
    # Each tool call returns a RestApiOutput instance
    if hasattr(task, "output") and task.output:
        print("\n=== TOOL CALL TRACE ===")
        # CrewAI stores tool results in task.output.raw or similar
        # depending on version; check your installed version's attributes


if __name__ == "__main__":
    main()

Verification checklist:

  1. Run python main.py — you should see the agent invoke rest_api_client multiple times in the verbose logs.
  2. Check that each tool call logs REST tool request and REST tool response with status codes and latency.
  3. The final answer should cite specific endpoints (e.g., “per GET /repos/crewAIInc/crewAI/pulls”).
  4. If you set GITHUB_TOKEN to an invalid value, the tool returns success=false with a 401 error — the agent should surface this rather than hallucinate.

Step 6: Add async support for parallel tool calls

CrewAI’s sequential process runs one task at a time, but a single task may benefit from parallel API calls (e.g., fetching issues and PRs simultaneously). Implement _arun and use asyncio.gather in a wrapper.

# tools/rest_tool.py (add to RestApiTool class)
import asyncio
from typing import List


class AsyncRestApiTool(RestApiTool):
    """Async variant for parallel execution within a single task."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._async_client: Optional[httpx.AsyncClient] = None

    @property
    def async_client(self) -> httpx.AsyncClient:
        if self._async_client is None:
            self._async_client = httpx.AsyncClient(
                timeout=httpx.Timeout(self._default_timeout),
                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
            )
        return self._async_client

    async def _arun(
        self,
        url: str,
        method: HttpMethod = HttpMethod.GET,
        headers: Optional[Dict[str, str]] = None,
        json_body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
        timeout_seconds: int = 30,
    ) -> RestApiOutput:
        # Reuse the sync logic but with async client
        start = time.perf_counter()
        request_headers = self._merge_headers(headers)

        try:
            response = await self.async_client.request(
                method=method.value,
                url=url,
                headers=request_headers,
                json=json_body,
                params=params,
                timeout=timeout_seconds,
            )
            elapsed_ms = int((time.perf_counter() - start) * 1000)

            data = response.json() if response.content else None
            return RestApiOutput(
                status_code=response.status_code,
                success=response.is_success,
                data=data,
                error=None if response.is_success else f"HTTP {response.status_code}",
                response_headers=dict(response.headers),
                elapsed_ms=elapsed_ms,
            )
        except Exception as e:
            elapsed_ms = int((time.perf_counter() - start) * 1000)
            return RestApiOutput(
                status_code=500,
                success=False,
                error=str(e)[:200],
                elapsed_ms=elapsed_ms,
            )

    async def batch_get(self, urls: List[str], headers: Optional[Dict] = None) -> List[RestApiOutput]:
        """Fetch multiple URLs in parallel."""
        tasks = [
            self._arun(url=url, method=HttpMethod.GET, headers=headers)
            for url in urls
        ]
        return await asyncio.gather(*tasks)

Update the task to use the async tool when parallel fetches make sense:

# tasks/github_tasks.py (updated)
def create_parallel_repo_task(repo_owner: str, repo_name: str) -> Task:
    agent = build_github_research_agent()
    # Replace the tool with async variant
    agent.tools = [
        AsyncRestApiTool(
            base_headers=agent.tools[0]._base_headers,
            default_timeout=agent.tools[0]._default_timeout,
        )
    ]

    return Task(
        description=(
            f"Fetch repository metadata, open issues, and open PRs for {repo_owner}/{repo_name} "
            "in parallel using the async tool's batch_get method. Then summarize."
        ),
        expected_output="Summary with counts and key details from all three endpoints",
        agent=agent,
    )

Step 7: Handle pagination and large responses

Most REST APIs paginate. Build a small helper into the tool so agents don’t need to reason about Link headers or cursor parameters.

# tools/rest_tool.py (add method to RestApiTool)
def paginated_get(
    self,
    url: str,
    headers: Optional[Dict[str, str]] = None,
    params: Optional[Dict[str, Any]] = None,
    max_pages: int = 5,
    page_param: str = "page",
    per_page_param: str = "per_page",
    per_page: int = 100,
) -> List[Dict[str, Any]]:
    """Fetch all pages up to max_pages, returning combined results."""
    all_items: List[Dict[str, Any]] = []
    current_params = {**params, page_param: 1, per_page_param: per_page} if params else {page_param: 1, per_page_param: per_page}

    for page_num in range(1, max_pages + 1):
        current_params[page_param] = page_num
        result = self._run(
            url=url,
            method=HttpMethod.GET,
            headers=headers,
            params=current_params,
        )

        if not result.success or not result.data:
            break

        # Handle GitHub-style array responses and paginated object responses
        items = result.data if isinstance(result.data, list) else result.data.get("items", [])
        if not items:
            break

        all_items.extend(items)

        # Check for next page via Link header (GitHub) or item count < per_page
        link_header = result.response_headers.get("link", "")
        if f'rel="next"' not in link_header and len(items) < per_page:
            break

    return all_items

Now the agent can call a single tool method and get consolidated results:

# In task description:
"- Use the tool's paginated_get method to fetch ALL open issues (up to 5 pages, 100 per page) "
 "from https://api.github.com/repos/{owner}/{repo}/issues"

Step 8: Secure credential handling in production

Never bake tokens into tool instances that get serialized or logged. Use a credential provider pattern that pulls secrets at runtime.

# tools/credential_provider.py
from abc import ABC, abstractmethod
from typing import Optional
import os


class CredentialProvider(ABC):
    @abstractmethod
    def get_token(self, service: str) -> Optional[str]:
        pass


class EnvCredentialProvider(CredentialProvider):
    def get_token(self, service: str) -> Optional[str]:
        return os.getenv(f"{service.upper()}_TOKEN")


class VaultCredentialProvider(CredentialProvider):
    """Example: HashiCorp Vault or AWS Secrets Manager integration."""
    def __init__(self, vault_client):
        self._vault = vault_client

    def get_token(self, service: str) -> Optional[str]:
        secret = self._vault.read_secret(f"crewai/{service}")
        return secret.get("token") if secret else None


def build_authenticated_tool(
    service: str,
    provider: CredentialProvider,
    base_url: str,
    extra_headers: Optional[Dict[str, str]] = None,
) -> RestApiTool:
    token = provider.get_token(service)
    if not token:
        raise ValueError(f"No credential found for service: {service}")

    headers = {"Authorization": f"Bearer {token}"}
    if extra_headers:
        headers.update(extra_headers)

    return RestApiTool(base_headers=headers)

Usage:

# agents/production_agent.py
from tools.credential_provider import EnvCredentialProvider, build_authenticated_tool

provider = EnvCredentialProvider()
github_tool = build_authenticated_tool(
    service="github",
    provider=provider,
    base_url="https://api.github.com",
    extra_headers={"Accept": "application/vnd.github+json"},
)

This keeps credentials out of agent memory, tool serialization, and log output.

Step 9: Add request/response middleware for cross-cutting concerns

Real systems need request ID propagation, distributed tracing headers, and rate-limit awareness. Add a middleware layer to the httpx client.

# tools/middleware.py
import uuid
import httpx
from typing import Callable, Awaitable


class RequestMiddleware:
    def __init__(self, service_name: str = "crewai-agent"):
        self.service_name = service_name

    def sync_middleware(self, request: httpx.Request) -> httpx.Request:
        request.headers.setdefault("X-Request-ID", str(uuid.uuid4()))
        request.headers.setdefault("X-Service-Name", self.service_name)
        return request

    async def async_middleware(self, request: httpx.Request) -> httpx.Request:
        return self.sync_middleware(request)


def create_instrumented_client(
    middleware: RequestMiddleware,
    **client_kwargs,
) -> httpx.Client:
    transport = httpx.HTTPTransport()
    client = httpx.Client(transport=transport, **client_kwargs)

    # Wrap send to inject headers
    original_send = client.send

    def instrumented_send(request: httpx.Request, **kwargs):
        request = middleware.sync_middleware(request)
        return original_send(request, **kwargs)

    client.send = instrumented_send
    return client

Wire it into the tool:

# tools/rest_tool.py (in __init__)
from tools.middleware import RequestMiddleware, create_instrumented_client

self._middleware = RequestMiddleware(service_name="github-analyst")
self._client = create_instrumented_client(
    middleware=self._middleware,
    timeout=httpx.Timeout(self._default_timeout),
    limits=httpx.Limits(max_connections=10),
)

Now every outbound request carries a traceable X-Request-ID, making debugging across services straightforward.

Step 10: Test the tool in isolation before handing to agents

Unit test the tool directly with a mock server. This catches schema mismatches, auth header issues, and error handling without burning API quota or involving LLM non-determinism.

# tests/test_rest_tool.py
import pytest
import httpx
from unittest.mock import AsyncMock, patch
from tools.rest_tool import RestApiTool, RestApiInput, RestApiOutput


@pytest.fixture
def mock_github_response():
    return {
        "id": 123456,
        "name": "test-repo",
        "full_name": "owner/test-repo",
        "private": False,
        "stargazers_count": 42,
    }


def test_rest_tool_success(mock_github_response):
    tool = RestApiTool(
        base_headers={"Authorization": "Bearer test-token"},
        default_timeout=10,
    )

    with patch.object(tool.client, "request") as mock_request:
        mock_response = httpx.Response(
            200,
            json=mock_github_response,
            headers={"Content-Type": "application/json"},
        )
        mock_request.return_value = mock_response

        result = tool._run(
            url="https://api.github.com/repos/owner/test-repo",
            method="GET",
        )

        assert isinstance(result, RestApiOutput)
        assert result.success is True
        assert result.status_code == 200
        assert result.data == mock_github_response
        assert result.elapsed_ms > 0

        # Verify auth header was injected
        call_args = mock_request.call_args
        assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-token"


def test_rest_tool_rate_limit_handling():
    tool = RestApiTool(base_headers={}, default_timeout=10)

    with patch.object(tool.client, "request") as mock_request:
        mock_response = httpx.Response(
            403,
            json={"message": "API rate limit exceeded"},
            headers={"X-RateLimit-Remaining": "0"},
        )
        mock_request.return_value = mock_response

        result = tool._run(url="https://api.github.com/repos/owner/repo")

        assert result.success is False
        assert result.status_code == 403
        assert "rate limit" in result.error.lower()


def test_rest_tool_timeout_retry():
    tool = RestApiTool(base_headers={}, default_timeout=1, max_retries=2)

    with patch.object(tool.client, "request") as mock_request:
        mock_request.side_effect = httpx.TimeoutException("Connection timed out")

        result = tool._run(url="https://api.github.com/repos/owner/repo")

        assert result.success is False
        assert result.status_code == 408
        assert mock_request.call_count == 3  # initial + 2 retries

Run with pytest tests/test_rest_tool.py -v. All tests should pass before you wire the tool into any agent.


You now have a REST API tool that handles auth, retries, pagination, async parallelism, observability, and secure credential management — ready for any CrewAI agent that needs live data. The same pattern applies to GraphQL endpoints, webhook callbacks, or internal service meshes. Swap the HTTP client for aiohttp or requests if your stack demands it; the interface contract stays the same.

Tagscrewaicustom-toolsrest-apiintegrations

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All crewai custom tools & integrations posts →