10 → Add a custom tool
Overview
What you'll build
You're adding a bespoke tool—todoScan—that searches for TODO comments across the repo. The focus isn't on scanning logic; it's on reusing the established pattern: schema + Effect service → layer → managed runtime → adapter.
Why it matters
- Demonstrates how to plug any custom capability into the agent.
- Reinforces dependency hygiene (
PathValidation,FileSystem,Layer.provide). - Prepares readers for performance considerations (Step 11) when adding more runtimes.
Big picture
Steps 03 and 06 built core tools; this step shows how to extend the toolbox cleanly. By the end you’ll have a blueprint for adding domain-specific features (linting, unit tests, deployment hooks, etc.).
Core concepts (Effect-TS)
Reusable Schemas
Define input/output with @effect/schema. It keeps adapters honest and helps the model understand tool parameters.
Managed Runtimes
ManagedRuntime.make wraps the layer stack so each tool executes with its dependencies available. This matches how FileTools and SearchTools are wired—consistency makes debugging easier.
Path Safety
Even custom tools should reuse PathValidation. If your tool reads or writes files, sand-box it the same way the built-in tools do.
Implementation
Implementation
Step 1: Define the tool module
import * as FileSystem from '@effect/platform/FileSystem'
import * as Path from '@effect/platform/Path'
import * as Context from 'effect/Context'
import * as Effect from 'effect/Effect'
import * as Layer from 'effect/Layer'
import * as Schema from 'effect/Schema'
import { PathValidation } from '../services/PathValidation'
// Define input and output schemas
const Parameters = Schema.Struct({
directory: Schema.optional(Schema.String),
})
const Result = Schema.Struct({
directory: Schema.String,
todos: Schema.Array(
Schema.Struct({
filePath: Schema.String,
line: Schema.Number,
text: Schema.String,
}),
),
})
export class TodoScanTools extends Context.Tag('TodoScanTools')<
TodoScanTools,
{
readonly todoScan: (
params: Schema.Schema.Type<typeof Parameters>,
) => Effect.Effect<Schema.Schema.Type<typeof Result>>
}
>() {}
export const layer = Layer.effect(
TodoScanTools,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const pathValidation = yield* PathValidation
const todoScan = ({ directory = '.' }: Schema.Schema.Type<typeof Parameters>) =>
Effect.gen(function* () {
const resolvedDir = yield* pathValidation.ensureWithinCwd(directory)
const entries = yield* fs.readDirectory(resolvedDir)
const todos = yield* Effect.forEach(entries, (entry) =>
Effect.gen(function* () {
const entryPath = path.join(resolvedDir, entry)
const stat = yield* fs
.stat(entryPath)
.pipe(Effect.catchAll(() => Effect.succeed<null>(null)))
if (!stat || stat.type !== 'File') {
return Effect.succeed<readonly never[]>([])
}
const content = yield* fs.readFileString(entryPath)
return content.split('\n').flatMap((line, index) =>
line.includes('TODO')
? [
{
filePath: pathValidation.relativePath(entryPath),
line: index + 1,
text: line.trim(),
},
]
: [],
)
}),
).pipe(Effect.map((chunks) => chunks.flat()))
return {
directory: pathValidation.relativePath(resolvedDir),
todos,
} as const
})
return { todoScan } as const
}),
)
Step 2: Wire the adapter
import { tool } from 'ai'
import * as Effect from 'effect/Effect'
import * as Schema from 'effect/Schema'
import type * as ManagedRuntime from 'effect/ManagedRuntime'
import { makeFileToolsForVercel } from './FileToolAdapters'
import { makeSearchToolsForVercel } from './SearchToolAdapters'
import { makeEditToolsForVercel } from './EditToolAdapters'
import { makeDirectoryToolsForVercel } from './DirectoryToolAdapters'
import { runToolEffect } from './runtime'
// Define schema for the custom tool
const TodoScanParameters = Schema.Struct({
directory: Schema.optional(Schema.String),
})
// Wrap with Standard Schema V1 for AI SDK v6 compatibility
const TodoScanToolSchema = Schema.standardSchemaV1(TodoScanParameters)
export const makeAllTools = (
fileToolsRuntime: ManagedRuntime.ManagedRuntime<typeof FileTools>,
searchToolsRuntime: ManagedRuntime.ManagedRuntime<typeof SearchTools>,
editToolsRuntime: ManagedRuntime.ManagedRuntime<typeof EditTools>,
directoryToolsRuntime: ManagedRuntime.ManagedRuntime<typeof DirectoryTools>,
todoToolsRuntime: ManagedRuntime.ManagedRuntime<typeof TodoScanTools>,
): ToolSet => ({
...makeFileToolsForVercel(fileToolsRuntime),
...makeSearchToolsForVercel(searchToolsRuntime),
...makeEditToolsForVercel(editToolsRuntime),
...makeDirectoryToolsForVercel(directoryToolsRuntime),
todoScan: tool({
description: 'Scan a directory for TODO comments',
inputSchema: TodoScanToolSchema,
execute: async (input: { directory?: string }) =>
runToolEffect(
todoToolsRuntime,
Effect.flatMap(TodoScanTools, (tools) => tools.todoScan(input)),
),
}),
})
Adapters validate parameters with @effect/schema and execute the Effect inside the managed runtime. Note that Schema.standardSchemaV1() wraps the Effect Schema to make it compatible with AI SDK v6's flexible schema system.
Step 3: Register the runtime in ToolRegistry
const todoToolsRuntime = ManagedRuntime.make(
TodoScanTools.layer.pipe(
Layer.provide(pathValidationStack),
Layer.provide(BunContext.layer),
Layer.orDie,
),
)
const toolsMap = makeAllTools(
fileToolsRuntime,
searchToolsRuntime,
editToolsRuntime,
directoryToolsRuntime,
todoToolsRuntime,
)
Make sure pathValidationStack (or equivalent) is reused so your custom tool stays inside the sandbox.
Testing & Validation
- Run the CLI and ask the assistant to call
todoScan—it should return a list of TODO comments. - Point the tool at a directory outside the repo (
../../); it should fail with a path validation error. - Remove the tool layer from
ToolRegistryto confirm the adapter throws (useful when debugging missing runtime wiring). - Add a second custom tool following the same pattern to reinforce the workflow.
Common Issues
| Problem | Likely Cause | Fix |
|---|---|---|
| Tool can access files outside project | Forgot to provide PathValidation.layer | Provide the same validation stack used by core tools |
| Schema validation fails | Parameters not matching Schema.Struct | Keep schemas in sync between tool and adapter |
| Runtime re-initializes on every call | Creating new ManagedRuntime per invocation | Create runtime once in ToolRegistry and reuse |
| Adapter not discovered | makeAllTools not merged into registry | Ensure ToolRegistry spreads the adapter map into VercelAI setup |
Connections
Builds on:
- 03 — First Tool — Shares the layer + adapter pattern
- 06 — Search with Glob and Ripgrep — Demonstrates additional tool stacks
Sets up:
- 11 — Performance & Concurrency — Optimizes multiple managed runtimes
Related code:
src/tools/TodoScanTools.tssrc/services/ToolRegistry.tssrc/adapters/index.tssrc/services/PathValidation.ts