Skip to main content

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

src/tools/TodoScanTools.ts
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

src/adapters/index.ts
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

src/services/ToolRegistry.ts
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
  1. Run the CLI and ask the assistant to call todoScan—it should return a list of TODO comments.
  2. Point the tool at a directory outside the repo (../../); it should fail with a path validation error.
  3. Remove the tool layer from ToolRegistry to confirm the adapter throws (useful when debugging missing runtime wiring).
  4. Add a second custom tool following the same pattern to reinforce the workflow.

Common Issues
ProblemLikely CauseFix
Tool can access files outside projectForgot to provide PathValidation.layerProvide the same validation stack used by core tools
Schema validation failsParameters not matching Schema.StructKeep schemas in sync between tool and adapter
Runtime re-initializes on every callCreating new ManagedRuntime per invocationCreate runtime once in ToolRegistry and reuse
Adapter not discoveredmakeAllTools not merged into registryEnsure ToolRegistry spreads the adapter map into VercelAI setup

Connections

Builds on:

Sets up:

Related code:

  • src/tools/TodoScanTools.ts
  • src/services/ToolRegistry.ts
  • src/adapters/index.ts
  • src/services/PathValidation.ts