n4nAI

CrewAI custom tools tutorial: file read and write access

Build CrewAI custom tools for file read and write operations with complete, runnable code examples and expected outputs.

n4n Team3 min read759 words

Audio narration

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

If you’re building agents that need to persist state, process documents, or generate artifacts, you’ll quickly hit the limits of CrewAI’s built-in toolset. This crewai file read write tool tutorial walks through creating production-ready custom tools for file I/O, including proper error handling, path validation, and integration patterns that scale beyond toy examples.

Prerequisites

  • Python 3.10+
  • CrewAI 0.28+ (pip install crewai)
  • An OpenAI API key or compatible endpoint (set OPENAI_API_KEY in your environment)
  • Basic familiarity with CrewAI agents, tasks, and crews

Create a fresh project directory and virtual environment:

mkdir crewai-file-tools && cd crewai-file-tools
python -m venv .venv
source .venv/bin/activate
pip install crewai python-dotenv

Designing the tool interface

CrewAI tools inherit from BaseTool and require three class attributes: name, description, and args_schema. The description is critical — it’s what the LLM reads to decide when and how to invoke your tool. Be specific about arguments, return values, and failure modes.

We’ll build two tools: FileReadTool and FileWriteTool. Both will operate relative to a configurable workspace root to prevent path traversal.

# tools/file_tools.py
from pathlib import Path
from typing import Type
from pydantic import BaseModel, Field
from crewai.tools import BaseTool


class FileReadInput(BaseModel):
    path: str = Field(..., description="Relative path to the file from workspace root")
    encoding: str = Field(default="utf-8", description="Text encoding (utf-8, latin-1, etc.)")


class FileWriteInput(BaseModel):
    path: str = Field(..., description="Relative path to the file from workspace root")
    content: str = Field(..., description="Content to write")
    mode: str = Field(default="w", description="Write mode: 'w' (overwrite) or 'a' (append)")
    encoding: str = Field(default="utf-8", description="Text encoding")
    create_dirs: bool = Field(default=True, description="Create parent directories if missing")


class FileReadTool(BaseTool):
    name: str = "file_read"
        "Read a text file from the workspace. Returns the file content as a string. "
        "Fails if the path escapes the workspace root or the file does not exist. "
        "Use 'encoding' for non-UTF-8 files (e.g., 'latin-1')."
    )
    args_schema: Type[BaseModel] = FileReadInput

    def __init__(self, workspace_root: str | Path = "."):
        super().__init__()
        self._workspace_root = Path(workspace_root).resolve()

    def _run(self, path: str, encoding: str = "utf-8") -> str:
        target = (self._workspace_root / path).resolve()
        if not self._is_within_workspace(target):
            raise ValueError(f"Path '{path}' escapes workspace root")
        if not target.exists():
            raise FileNotFoundError(f"File not found: {path}")
        if not target.is_file():
            raise ValueError(f"Path is not a file: {path}")
        return target.read_text(encoding=encoding)

    def _is_within_workspace(self, path: Path) -> bool:
        try:
            path.relative_to(self._workspace_root)
            return True
        except ValueError:
            return False


class FileWriteTool(BaseTool):
    name: str = "file_write"
        "Write or append text to a file in the workspace. Creates parent directories if "
        "create_dirs=true (default). Fails if the path escapes the workspace root. "
        "Use mode='a' to append, mode='w' to overwrite."
    )
    args_schema: Type[BaseModel] = FileWriteInput

    def __init__(self, workspace_root: str | Path = "."):
        super().__init__()
        self._workspace_root = Path(workspace_root).resolve()

    def _run(
        self,
        path: str,
        content: str,
        mode: str = "w",
        encoding: str = "utf-8",
        create_dirs: bool = True,
    ) -> str:
        if mode not in ("w", "a"):
            raise ValueError("mode must be 'w' or 'a'")
        target = (self._workspace_root / path).resolve()
        if not self._is_within_workspace(target):
            raise ValueError(f"Path '{path}' escapes workspace root")
        if create_dirs:
            target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding=encoding)
        action = "Appended to" if mode == "a" else "Wrote"
        return f"{action} {path} ({len(content)} characters)"

    def _is_within_workspace(self, path: Path) -> bool:
        try:
            path.relative_to(self._workspace_root)
            return True
        except ValueError:
            return False

Checkpoint: Verify the tools import cleanly.

python -c "from tools.file_tools import FileReadTool, FileWriteTool; print('OK')"

Expected output:

OK

Wiring tools into an agent

Tools are just Python objects — pass them to an agent’s tools list. The agent’s role and goal should reference the tools explicitly so the planner knows they exist.

# agents/file_agent.py
from crewai import Agent
from tools.file_tools import FileReadTool, FileWriteTool


def create_file_agent(workspace_root: str = "workspace") -> Agent:
    return Agent(
        role="File operations specialist",
        goal=(
            "Read, write, and manipulate files in the workspace using the provided tools. "
            "Always use relative paths. Never assume absolute paths."
        ),
        backstory=(
            "You are a meticulous file operator. You validate paths, handle encoding issues, "
            "and report exact byte counts. You never hallucinate file contents."
        ),
        tools=[FileReadTool(workspace_root), FileWriteTool(workspace_root)],
        verbose=True,
        allow_delegation=False,
    )

Building a task that exercises both tools

Create a task that reads a template, transforms it, and writes the result. This demonstrates the read→process→write loop common in document pipelines.

# tasks/file_tasks.py
from crewai import Task
from agents.file_agent import create_file_agent


def create_process_template_task(workspace_root: str = "workspace") -> Task:
    agent = create_file_agent(workspace_root)
    return Task(
        description=(
            "1. Read 'templates/report_template.md' from the workspace.\n"
            "2. Replace the placeholder '{{DATE}}' with today's date in YYYY-MM-DD format.\n"
            "3. Replace '{{TOPIC}}' with 'CrewAI Custom Tools Tutorial'.\n"
            "4. Write the result to 'output/generated_report.md'.\n"
            "5. Read back the written file and return its content as the final output."
        ),
        expected_output=(
            "The full content of the generated report file, confirming both tools worked."
        ),
        agent=agent,
    )

Creating the workspace and running the crew

Set up the directory structure, seed a template, and execute.

# main.py
import os
from datetime import date
from pathlib import Path
from crewai import Crew
from tasks.file_tasks import create_process_template_task


def setup_workspace(root: Path) -> None:
    templates = root / "templates"
    output = root / "output"
    templates.mkdir(parents=True, exist_ok=True)
    output.mkdir(parents=True, exist_ok=True)

    template_content = """# {{TOPIC}}

*Generated on {{DATE}}*

## Overview

This report was created by a CrewAI agent using custom file read/write tools.

## Key Points

- Custom tools extend agent capabilities beyond built-ins
- Path validation prevents directory traversal
- Explicit encoding handling avoids corruption
- Tools return actionable feedback for the agent

## Next Steps

Explore chaining multiple file operations in a single task.
"""
    (templates / "report_template.md").write_text(template_content)


def main() -> None:
    workspace = Path("workspace").resolve()
    setup_workspace(workspace)

    task = create_process_template_task(str(workspace))
    crew = Crew(agents=[task.agent], tasks=[task], verbose=True)

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


if __name__ == "__main__":
    main()

Run it:

python main.py

Expected output (truncated for readability):

[2024-01-15 10:23:41] INFO: CrewAI Agent: File operations specialist
[2024-01-15 10:23:41] INFO: Task: 1. Read 'templates/report_template.md'...
[2024-01-15 10:23:42] INFO: Tool file_read called with args: {'path': 'templates/report_template.md', 'encoding': 'utf-8'}
[2024-01-15 10:23:42] INFO: Tool file_read returned: # {{TOPIC}}\n\n*Generated on {{DATE}}*\n...
[2024-01-15 10:23:43] INFO: Tool file_write called with args: {'path': 'output/generated_report.md', 'content': '# CrewAI Custom Tools Tutorial\n\n*Generated on 2024-01-15*\n...', 'mode': 'w', 'encoding': 'utf-8', 'create_dirs': True}
[2024-01-15 10:23:43] INFO: Tool file_write returned: Wrote output/generated_report.md (542 characters)
[2024-01-15 10:23:44] INFO: Tool file_read called with args: {'path': 'output/generated_report.md', 'encoding': 'utf-8'}
[2024-01-15 10:23:44] INFO: Tool file_read returned: # CrewAI Custom Tools Tutorial\n\n*Generated on 2024-01-15*\n...

=== FINAL RESULT ===
# CrewAI Custom Tools Tutorial

*Generated on 2024-01-15*

## Overview

This report was created by a CrewAI agent using custom file read/write tools.
...

Checkpoint: Verify the generated file exists and matches expectations.

cat workspace/output/generated_report.md

Adding a list tool for directory operations

Real workflows often need to discover files before reading them. Add a FileListTool that returns structured JSON — LLMs parse JSON more reliably than free text.

# tools/file_tools.py (add to existing file)
import json
from typing import Type


class FileListInput(BaseModel):
    path: str = Field(default=".", description="Relative directory path from workspace root")
    pattern: str = Field(default="*", description="Glob pattern (e.g., '*.md', '**/*.txt')")
    recursive: bool = Field(default=False, description="Recurse into subdirectories")


class FileListTool(BaseTool):
    name: str = "file_list"
        "List files in the workspace matching a glob pattern. Returns a JSON array of objects "
        "with 'path' (relative), 'size' (bytes), 'is_file' (bool), and 'modified' (ISO timestamp). "
        "Use pattern='**/*' with recursive=true for full tree."
    )
    args_schema: Type[BaseModel] = FileListInput

    def __init__(self, workspace_root: str | Path = "."):
        super().__init__()
        self._workspace_root = Path(workspace_root).resolve()

    def _run(self, path: str = ".", pattern: str = "*", recursive: bool = False) -> str:
        target = (self._workspace_root / path).resolve()
        if not self._is_within_workspace(target):
            raise ValueError(f"Path '{path}' escapes workspace root")
        if not target.exists() or not target.is_dir():
            raise NotADirectoryError(f"Not a directory: {path}")

        glob_method = target.rglob if recursive else target.glob
        files = []
        for entry in glob_method(pattern):
            if not self._is_within_workspace(entry):
                continue
            stat = entry.stat()
            rel = entry.relative_to(self._workspace_root)
            files.append({
                "path": str(rel),
                "size": stat.st_size,
                "is_file": entry.is_file(),
                "modified": stat.st_mtime,
            })
        return json.dumps(files, indent=2)

    def _is_within_workspace(self, path: Path) -> bool:
        try:
            path.relative_to(self._workspace_root)
            return True
        except ValueError:
            return False

Update the agent to include the new tool:

# agents/file_agent.py (update imports and function)
from tools.file_tools import FileReadTool, FileWriteTool, FileListTool


def create_file_agent(workspace_root: str = "workspace") -> Agent:
    return Agent(
        role="File operations specialist",
        goal=(
            "Read, write, list, and manipulate files in the workspace using the provided tools. "
            "Always use relative paths. Never assume absolute paths."
        ),
        backstory=(
            "You are a meticulous file operator. You validate paths, handle encoding issues, "
            "and report exact byte counts. You never hallucinate file contents."
        ),
        tools=[
            FileReadTool(workspace_root),
            FileWriteTool(workspace_root),
            FileListTool(workspace_root),
        ],
        verbose=True,
        allow_delegation=False,
    )

Create a task that uses all three tools:

# tasks/file_tasks.py (add new task)
def create_audit_workspace_task(workspace_root: str = "workspace") -> Task:
    agent = create_file_agent(workspace_root)
    return Task(
        description=(
            "1. List all files in the workspace recursively using pattern '**/*'.\n"
            "2. For each .md file found, read its content and count words.\n"
            "3. Write a summary report to 'output/audit_report.json' with this structure:\n"
            "   {\n"
            "     'total_files': int,\n"
            "     'markdown_files': int,\n"
            "     'total_words': int,\n"
            "     'files': [\n"
            "       {'path': str, 'word_count': int, 'size_bytes': int}\n"
            "     ]\n"
            "   }\n"
            "4. Return the JSON content as final output."
        ),
        expected_output="Valid JSON audit report with file counts and word totals.",
        agent=agent,
    )

Run the audit:

# main.py (add to main())
from tasks.file_tasks import create_audit_workspace_task


def main() -> None:
    workspace = Path("workspace").resolve()
    setup_workspace(workspace)

    # First task: template processing
    task1 = create_process_template_task(str(workspace))
    crew1 = Crew(agents=[task1.agent], tasks=[task1], verbose=True)
    crew1.kickoff()

    # Second task: workspace audit
    task2 = create_audit_workspace_task(str(workspace))
    crew2 = Crew(agents=[task2.agent], tasks=[task2], verbose=True)
    result = crew2.kickoff()
    print("\n=== AUDIT RESULT ===")
    print(result)

Expected audit output (formatted):

{
  "total_files": 3,
  "markdown_files": 2,
  "total_words": 156,
  "files": [
    {"path": "templates/report_template.md", "word_count": 78, "size_bytes": 542},
    {"path": "output/generated_report.md", "word_count": 78, "size_bytes": 542}
  ]
}

Error handling patterns that matter

The tools above raise exceptions on failure. CrewAI catches these and feeds the error message back to the agent, which can retry or escalate. But you can do better by returning structured error objects that the agent can reason about.

# tools/file_tools.py (replace FileReadTool._run)
from dataclasses import dataclass, asdict
import json


@dataclass
class ToolResult:
    success: bool
    data: str | None = None
    error: str | None = None

    def to_json(self) -> str:
        return json.dumps(asdict(self))


class FileReadTool(BaseTool):
    # ... name, description, args_schema unchanged ...

    def _run(self, path: str, encoding: str = "utf-8") -> str:
        try:
            target = (self._workspace_root / path).resolve()
            if not self._is_within_workspace(target):
                return ToolResult(success=False, error=f"Path '{path}' escapes workspace root").to_json()
            if not target.exists():
                return ToolResult(success=False, error=f"File not found: {path}").to_json()
            if not target.is_file():
                return ToolResult(success=False, error=f"Path is not a file: {path}").to_json()
            content = target.read_text(encoding=encoding)
            return ToolResult(success=True, data=content).to_json()
        except UnicodeDecodeError as e:
            return ToolResult(success=False, error=f"Encoding error: {e}").to_json()
        except Exception as e:
            return ToolResult(success=False, error=f"Unexpected error: {e}").to_json()

Update the agent’s backstory to mention JSON responses:

# agents/file_agent.py (update backstory)
backstory=(
    "You are a meticulous file operator. You validate paths, handle encoding issues, "
    "and report exact byte counts. You never hallucinate file contents. "
    "All tools return JSON with 'success', 'data', and 'error' fields — always check 'success' first."
),

Now the agent can distinguish between “file not found” (retry with different path) and “encoding error” (try different encoding) without parsing error strings.

The _is_within_workspace check using relative_to() handles .. traversal but follows symlinks. If your workspace might contain symlinks (common in CI/CD or mounted volumes), resolve both paths first:

def _is_within_workspace(self, path: Path) -> bool:
    try:
        # Resolve symlinks on both sides
        resolved_workspace = self._workspace_root.resolve()
        resolved_path = path.resolve()
        resolved_path.relative_to(resolved_workspace)
        return True
    except ValueError:
        return False

This prevents a symlink inside the workspace from pointing outside it.

Binary file support

Text tools fail on images, PDFs, or encrypted files. Add a FileReadBytesTool for binary-safe operations:

# tools/file_tools.py (add new tool)
import base64


class FileReadBytesInput(BaseModel):
    path: str = Field(..., description="Relative path to the file from workspace root")
    max_bytes: int = Field(default=10_485_760, description="Max bytes to read (default 10MB)")


class FileReadBytesTool(BaseTool):
    name: str = "file_read_bytes"
        "Read a file as base64-encoded binary. Use for images, PDFs, or any non-text file. "
        "Returns JSON with 'success', 'data' (base64 string), 'size' (bytes), and 'error'. "
        "Fails if file exceeds max_bytes."
    )
    args_schema: Type[BaseModel] = FileReadBytesInput

    def __init__(self, workspace_root: str | Path = "."):
        super().__init__()
        self._workspace_root = Path(workspace_root).resolve()

    def _run(self, path: str, max_bytes: int = 10_485_760) -> str:
        try:
            target = (self._workspace_root / path).resolve()
            if not self._is_within_workspace(target):
                return ToolResult(success=False, error=f"Path '{path}' escapes workspace root").to_json()
            if not target.exists() or not target.is_file():
                return ToolResult(success=False, error=f"File not found: {path}").to_json()
            size = target.stat().st_size
            if size > max_bytes:
                return ToolResult(success=False, error=f"File too large: {size} bytes (max {max_bytes})").to_json()
            data = base64.b64encode(target.read_bytes()).decode("ascii")
            return ToolResult(success=True, data=data, error=None).to_json()
        except Exception as e:
            return ToolResult(success=False, error=f"Unexpected error: {e}").to_json()

    def _is_within_workspace(self, path: Path) -> bool:
        try:
            self._workspace_root.resolve().relative_to(path.resolve())
            return True
        except ValueError:
            return False

Note: The relative_to logic is inverted above — fix it to match the pattern used in other tools.

Testing tools in isolation

Before wiring into a crew, unit-test each tool directly. This catches path logic bugs faster than debugging through the LLM loop.

# tests/test_file_tools.py
import tempfile
from pathlib import Path
from tools.file_tools import FileReadTool, FileWriteTool, FileListTool
import json


def test_write_then_read():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        write_tool = FileWriteTool(root)
        read_tool = FileReadTool(root)

        result = write_tool._run("hello.txt", "Hello, world!")
        assert "Wrote hello.txt" in result

        content = read_tool._run("hello.txt")
        assert content == "Hello, world!"


def test_path_traversal_blocked():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        read_tool = FileReadTool(root)

        # Create a file outside workspace
        outside = Path(tmp).parent / "secret.txt"
        outside.write_text("secret")

        # Try to escape via relative path
        result = read_tool._run("../secret.txt")
        assert "escapes workspace root" in result


def test_list_returns_json():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        (root / "a.txt").write_text("a")
        (root / "sub").mkdir()
        (root / "sub" / "b.txt").write_text("b")

        list_tool = FileListTool(root)
        result = json.loads(list_tool._run(pattern="**/*", recursive=True))
        paths = {item["path"] for item in result}
        assert "a.txt" in paths
        assert "sub/b.txt" in paths


if __name__ == "__main__":
    test_write_then_read()
    test_path_traversal_blocked()
    test_list_returns_json()
    print("All tests passed")

Run tests:

python tests/test_file_tools.py

Expected:

All tests passed

Integrating with n4n.ai for model routing

If your crew uses multiple models — say, a cheap model for file ops and a reasoning model for synthesis — you can route via n4n.ai’s OpenAI-compatible endpoint. Set OPENAI_BASE_URL=https://api.n4n.ai/v1 and OPENAI_API_KEY to your n4n.ai key. The gateway handles fallback across 240+ models and forwards provider cache-control hints, so repeated file-read prompts can hit cached responses without code changes.

export OPENAI_BASE_URL=https://api.n4n.ai/v1
export OPENAI_API_KEY=your-n4n-key
python main.py

Common pitfalls

Pitfall Symptom Fix
Agent ignores tools “I don’t have access to files” Ensure tool description is explicit; include tool names in agent goal
Path errors on Windows ValueError on resolve() Use Path(path).resolve(strict=False) or handle OSError
Encoding corruption Garbled output on non-UTF-8 files Add encoding parameter; default to utf-8 but allow override
Infinite retry loops Agent re-reads same missing file Return structured errors; instruct agent to check success field
Large file OOM Process killed on 500MB log Enforce max_bytes in read tools; stream large files in chunks

Extending further

  • FileWatchTool: Poll a directory for changes, return new/modified files since last call
  • FilePatchTool: Apply unified diffs instead of full rewrites — safer for concurrent edits
  • ArchiveTool: Create/extract zip/tar.gz for artifact bundling
  • SearchTool: Ripgrep wrapper for content search across workspace

Each follows the same pattern: validate path, enforce workspace boundary, return structured JSON, document the schema in description.


You now have a complete, tested toolkit for file I/O in CrewAI. The patterns here — workspace isolation, structured results, unit-testable tools — apply to any custom tool you build. Start with the three tools in this tutorial, add domain-specific ones as needed, and keep the agent’s backstory aligned with what the tools actually return.

Tagscrewaicustom-toolsfile-iotutorial

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 →