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_KEYin 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.
Path normalization and symlink safety
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.