Building a File Search Skill
A complete, annotated implementation of a file search skill with glob pattern matching, result formatting, and thorough tests.
This example walks through building a file search skill, which is about the simplest useful thing an agent can do. We cover the skill definition, implementation, error handling, and tests.
What we’re building
A skill that lets an agent search for files by name pattern within a project directory. The agent provides a glob pattern (like **/*.ts) and gets back a list of matching file paths.
It makes a good first skill to study because the scope is small: one input, one output, clear error cases.
The skill definition
First, the interface that the agent sees. This is the part that matters most. If the description is wrong or vague, the agent will misuse the skill no matter how good the implementation is.
const fileSearchSkill = {
name: "search_files",
description: `Search for files by name pattern in the project directory.
Use this when you need to find files matching a specific name or
extension (e.g., find all TypeScript files, locate a config file
by name). Returns matching file paths sorted by modification time.
Do NOT use this for searching file *contents* — use grep instead.`,
parameters: {
type: "object",
properties: {
pattern: {
type: "string",
description:
'Glob pattern to match files (e.g., "**/*.ts", "src/**/*.test.js")',
},
path: {
type: "string",
description: "Directory to search in. Defaults to the project root.",
},
},
required: ["pattern"],
},
};
A few things to notice here. The description says when to use this skill and when not to (“Do NOT use this for searching file contents”). Parameter names are obvious (pattern, not q or glob). The descriptions include concrete examples. And path is optional, defaulting to the project root, because most of the time the agent just wants to search from the top.
The implementation
import { glob } from "glob";
import { stat } from "fs/promises";
import { resolve } from "path";
interface SearchResult {
matches: Array<{
path: string;
modified: string;
}>;
totalMatches: number;
truncated: boolean;
searchPath: string;
}
async function searchFiles(
pattern: string,
searchPath?: string,
): Promise<SearchResult> {
const basePath = resolve(searchPath || process.cwd());
const MAX_RESULTS = 200;
// Find matching files
const files = await glob(pattern, {
cwd: basePath,
nodir: true,
dot: false, // Skip hidden files by default
ignore: ["**/node_modules/**", "**/.git/**"],
});
// Get modification times and sort
const withStats = await Promise.all(
files.slice(0, MAX_RESULTS).map(async (file) => {
const fullPath = resolve(basePath, file);
const fileStat = await stat(fullPath).catch(() => null);
return {
path: file,
modified: fileStat
? fileStat.mtime.toISOString().split("T")[0]
: "unknown",
};
}),
);
withStats.sort((a, b) => (b.modified > a.modified ? 1 : -1));
return {
matches: withStats,
totalMatches: files.length,
truncated: files.length > MAX_RESULTS,
searchPath: basePath,
};
}
Why these choices
Results are capped at 200 so we don’t blow up the agent’s context with thousands of file paths. The truncated flag tells the agent the results are incomplete, so it can narrow its search. Sorting by modification time puts the most recently changed files first, which is usually what you want. We skip node_modules and .git by default because they’re noise. And if a file disappears between the listing and the stat call (it happens), we return “unknown” instead of crashing.
Error handling
async function searchFilesWithErrorHandling(
pattern: string,
searchPath?: string,
): Promise<
SearchResult | { success: false; error: string; suggestion: string }
> {
try {
// Validate the pattern isn't empty
if (!pattern || pattern.trim() === "") {
return {
success: false,
error: "Empty search pattern",
suggestion: "Provide a glob pattern like '**/*.ts' or 'src/**/*.js'",
};
}
// Validate the search path exists
if (searchPath) {
const pathStat = await stat(resolve(searchPath)).catch(() => null);
if (!pathStat?.isDirectory()) {
return {
success: false,
error: `Search path does not exist or is not a directory: ${searchPath}`,
suggestion:
"Check the path and try again. Use search_files with pattern '**' to see available directories.",
};
}
}
return await searchFiles(pattern, searchPath);
} catch (err) {
return {
success: false,
error: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
suggestion:
"Check the glob pattern syntax. Common patterns: '**/*.ts', 'src/**/*.js', '*.config.*'",
};
}
}
Every error case returns a description of what went wrong and a suggestion for what the agent should try next. Without that suggestion, the agent will often just retry the same failing request or give up entirely.
Testing
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
describe("searchFiles", () => {
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "search-test-"));
// Create test file structure
await mkdir(join(testDir, "src"), { recursive: true });
await mkdir(join(testDir, "src", "utils"), { recursive: true });
await writeFile(join(testDir, "src", "index.ts"), "");
await writeFile(join(testDir, "src", "utils", "helpers.ts"), "");
await writeFile(join(testDir, "src", "utils", "helpers.test.ts"), "");
await writeFile(join(testDir, "README.md"), "");
await writeFile(join(testDir, "package.json"), "{}");
});
afterEach(async () => {
await rm(testDir, { recursive: true });
});
it("finds files matching a glob pattern", async () => {
const result = await searchFiles("**/*.ts", testDir);
expect(result.totalMatches).toBe(3);
expect(result.matches.map((m) => m.path)).toContain("src/index.ts");
});
it("respects the search path", async () => {
const result = await searchFiles("*.ts", join(testDir, "src"));
expect(result.totalMatches).toBe(1);
expect(result.matches[0].path).toBe("index.ts");
});
it("returns empty results for no matches", async () => {
const result = await searchFiles("**/*.py", testDir);
expect(result.totalMatches).toBe(0);
expect(result.matches).toEqual([]);
});
it("indicates when results are truncated", async () => {
// This test would need many files to trigger truncation
const result = await searchFiles("**/*", testDir);
expect(result.truncated).toBe(false);
});
it("handles invalid paths gracefully", async () => {
const result = await searchFilesWithErrorHandling(
"**/*.ts",
"/nonexistent/path",
);
expect(result).toHaveProperty("success", false);
expect(result).toHaveProperty("suggestion");
});
it("handles empty patterns gracefully", async () => {
const result = await searchFilesWithErrorHandling("", testDir);
expect(result).toHaveProperty("success", false);
});
});
What the tests cover
The happy path (finding files that exist), scoped searches within a subdirectory, empty results (no matches is not an error), truncation flag behavior, and invalid inputs like bad paths and empty patterns.
Key takeaways
The skill description is your most important code, so spend time on it. Return structured data with metadata like counts and truncation flags. Include suggestions in error responses so the agent can self-correct. Set defaults that handle 80% of cases without configuration. And test error cases, because agents will send unexpected inputs.