Skip to content

Tools and Toolkits

Tools let a language model call your code. You describe each tool with a name, a description, and a Schema for its parameters; the model decides when to call it and fills in the parameters; the framework decodes them, runs your handler, and feeds the result back to the model. Group related tools into a toolkit and hand it to LanguageModel.generateText — resolution happens automatically.

A Tool pairs a parameter schema (what the model fills in) with a success schema (what your handler returns). Descriptions on the tool and on individual parameters are sent to the model to help it decide when and how to call.

import { Effect, Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const ProductId = Schema.String.pipe(Schema.brand("ProductId")).annotate({
description: "A unique identifier for a product, e.g. 'p-123'"
})
class Product extends Schema.Class<Product>("acme/domain/Product")({
id: ProductId,
name: Schema.String,
price: Schema.Number
}) {}
const SearchProducts = Tool.make("SearchProducts", {
description: "Search the product catalog by keyword",
parameters: Schema.Struct({
query: Schema.String.annotate({
// Per-parameter descriptions give the model even better guidance.
description: "The search query, e.g. 'wireless headphones'"
}),
// Schema defaults mean the model can omit this and still get a valid value.
maxResults: Schema.Number.pipe(
Schema.withDecodingDefault(Effect.succeed(10))
).annotate({ description: "Maximum number of results to return" })
}),
// The handler must return a value matching this schema.
success: Schema.Array(Product),
// How handler errors are surfaced:
// - "error" (default): handler failures go to the calling effect's error channel.
// - "return": handler failures are captured and returned as the tool result.
failureMode: "error"
})
const GetInventory = Tool.make("GetInventory", {
description: "Check current stock level for a product",
parameters: Schema.Struct({ productId: ProductId }),
success: Schema.Struct({ productId: ProductId, available: Schema.Number })
})

A tool with no parameters can omit parameters entirely; Tool.make defaults it to Tool.EmptyParams (an empty object). Likewise success defaults to Schema.Void and failure to Schema.Never.

const GetCurrentTime = Tool.make("GetCurrentTime", {
description: "Returns the current timestamp",
success: Schema.Number
})

Grouping tools into a toolkit and implementing handlers

Section titled “Grouping tools into a toolkit and implementing handlers”

Toolkit.make collects any number of tools into a single, typed toolkit. Its toLayer method takes an effect producing the handlers — one per tool — and returns a Layer that satisfies the toolkit’s handler requirements. Each handler receives decoded parameters and returns an Effect of the success type.

import { Effect } from "effect"
import { Toolkit } from "effect/unstable/ai"
const ProductToolkit = Toolkit.make(SearchProducts, GetInventory)
const ProductToolkitLayer = ProductToolkit.toLayer(Effect.gen(function*() {
// The handler-building effect can acquire other services first —
// e.g. a database client used inside the handlers below.
// const db = yield* Database
return ProductToolkit.of({
SearchProducts: Effect.fn("ProductToolkit.SearchProducts")(
function*({ query, maxResults }) {
return [
new Product({ id: ProductId.make("p-1"), name: `${query} widget`, price: 19.99 }),
new Product({ id: ProductId.make("p-2"), name: `${query} gadget`, price: 29.99 })
].slice(0, maxResults)
}
),
GetInventory: Effect.fn("ProductToolkit.GetInventory")(
function*({ productId }) {
return { productId, available: 42 }
}
)
})
}))

ProductToolkit.of({ ... }) gives you type-checked handlers: the keys must match the tool names, each handler’s argument is the decoded parameter type, and its result must match the tool’s success schema. If you don’t need to acquire services first, you can pass the handler record directly to toLayer(...) without wrapping it in Effect.gen.

Pass the toolkit to generateText. The model may call any tool in it; the framework resolves parameters, invokes your handlers, feeds results back, and loops until the model produces a final answer. The resulting response exposes toolCalls and toolResults so you can inspect what happened.

import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai"
import { Config, Context, Effect, Layer, Schema } from "effect"
import { AiError, LanguageModel } from "effect/unstable/ai"
import { FetchHttpClient } from "effect/unstable/http"
const OpenAiClientLayer = OpenAiClient.layerConfig({
apiKey: Config.redacted("OPENAI_API_KEY")
}).pipe(Layer.provide(FetchHttpClient.layer))
class ProductAssistantError extends Schema.TaggedErrorClass<ProductAssistantError>()(
"ProductAssistantError",
{ reason: AiError.AiErrorReason }
) {}
class ProductAssistant extends Context.Service<ProductAssistant, {
answer(question: string): Effect.Effect<string, ProductAssistantError>
}>()("docs/ProductAssistant") {
static readonly layer = Layer.effect(
ProductAssistant,
Effect.gen(function*() {
// Yield the toolkit definition to get the live handlers.
const toolkit = yield* ProductToolkit
const model = yield* OpenAiLanguageModel.model("gpt-5.2").captureRequirements
const answer = Effect.fn("ProductAssistant.answer")(
function*(question: string) {
const response = yield* LanguageModel.generateText({
prompt: question,
toolkit,
// "required" forces a tool call before any text. Default is "auto".
toolChoice: "required"
})
// Inspect what the model did this turn.
for (const call of response.toolCalls) {
yield* Effect.log(`Tool call: ${call.name} id=${call.id}`)
}
for (const result of response.toolResults) {
yield* Effect.log(`Tool result: ${result.name} isFailure=${result.isFailure}`)
}
return response.text
},
Effect.provide(model),
// Map the AI error (the only thing in the error channel) to our domain error.
Effect.catchTag(
"AiError",
(error) => Effect.fail(new ProductAssistantError({ reason: error.reason }))
)
)
return ProductAssistant.of({ answer })
})
).pipe(
// The handler Layer must be provided so the framework can run tool calls.
Layer.provide(ProductToolkitLayer),
Layer.provide(OpenAiClientLayer)
)
}
  1. The model picks a tool and produces a call with decoded, schema-validated parameters.

  2. The framework runs your handler for that tool and captures its result (or failure, per failureMode).

  3. The result is appended to the conversation and sent back to the model.

  4. The loop repeats until the model stops requesting tools and returns text.

toolChoice on generateText steers the model (ToolChoice in LanguageModel):

  • "auto" (default) — the model decides whether and which tool to call.
  • "required" — the model must call some tool before answering.
  • "none" — the model must not call a tool.
  • { tool: "SearchProducts" } — the model must call that specific tool.
  • { mode?: "auto" | "required", oneOf: ["SearchProducts"] } — restrict to a subset of tools, optionally forcing a call from that subset.

failureMode decides what happens when a handler fails:

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
// "error" (default): a handler failure short-circuits into the calling effect's
// error channel. The model never sees the failure.
const ChargeCard = Tool.make("ChargeCard", {
parameters: Schema.Struct({ amount: Schema.Number }),
success: Schema.String,
failure: Schema.Struct({ code: Schema.String }),
failureMode: "error"
})
// "return": a handler failure is captured and returned to the model as the tool
// result, so the model can read it and try to recover (e.g. retry differently).
const LookupOrder = Tool.make("LookupOrder", {
parameters: Schema.Struct({ orderId: Schema.String }),
success: Schema.Struct({ status: Schema.String }),
failure: Schema.Struct({ message: Schema.String }),
failureMode: "return"
})

Mark a tool with needsApproval to gate execution. It can be a static boolean or a function that inspects the decoded parameters and a NeedsApprovalContext (the tool-call id and the messages leading up to the call). The function may return a boolean or an Effect<boolean>.

import { Effect, Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const DeleteFile = Tool.make("DeleteFile", {
description: "Delete a file from the workspace",
parameters: Schema.Struct({ path: Schema.String }),
success: Schema.Void,
// Always require approval for this destructive tool.
needsApproval: true
})
const Refund = Tool.make("Refund", {
parameters: Schema.Struct({ amount: Schema.Number }),
success: Schema.String,
// Dynamically require approval only for large refunds.
needsApproval: ({ amount }, ctx) =>
Effect.sync(() => amount > 100 || ctx.messages.length > 20)
})

Some providers offer built-in, server-side tools (web search, code interpreter, …). These run on the provider and need no handler from you (unless the provider returns data you want to post-process). Provider packages ship pre-built definitions you can drop into any toolkit.

import { OpenAiTool } from "@effect/ai-openai"
import { Effect } from "effect"
import { Toolkit } from "effect/unstable/ai"
// A provider-defined tool, executed server-side by OpenAI.
const webSearch = OpenAiTool.WebSearch({ search_context_size: "medium" })
// Mix user-defined and provider-defined tools freely.
const AssistantToolkit = Toolkit.make(SearchProducts, GetInventory, webSearch)
// Only the user-defined tools need handlers in `toLayer`; the provider-defined
// `WebSearch` is resolved by the provider and is absent here.
const AssistantToolkitLayer = AssistantToolkit.toLayer(Effect.gen(function*() {
return AssistantToolkit.of({
SearchProducts: Effect.fn("AssistantToolkit.SearchProducts")(
function*({ query, maxResults }) {
return [
new Product({ id: ProductId.make("p-1"), name: `${query} widget`, price: 19.99 })
].slice(0, maxResults)
}
),
GetInventory: Effect.fn("AssistantToolkit.GetInventory")(
function*({ productId }) {
return { productId, available: 42 }
}
)
})
}))

To define your own provider-defined tool, use Tool.providerDefined. Set requiresHandler: true if your application must process the provider’s returned data — only then does the tool appear in the toolkit’s handler record.


Everything below comes from the Tool module (effect/unstable/ai). Constructors build tool definitions; tools do nothing on their own until placed in a toolkit with handlers.

The primary constructor for an application-owned tool. Options: description, parameters, success, failure, failureMode ("error" | "return"), dependencies (request-level service requirements), and needsApproval. All are optional — omitted parameters defaults to EmptyParams, success to Schema.Void, failure to Schema.Never, failureMode to "error".

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const GetWeather = Tool.make("GetWeather", {
description: "Get current weather for a location",
parameters: Schema.Struct({ location: Schema.String }),
success: Schema.Struct({ temperature: Schema.Number })
})
console.log(GetWeather.name) // => "GetWeather"
console.log(GetWeather.id) // => "effect/ai/Tool/GetWeather"
console.log(GetWeather.failureMode) // => "error"

Tool values also expose builder methods: addDependency(tag), setParameters(schema), setSuccess(schema), setFailure(schema), annotate(tag, value), and annotateMerge(context) — each returns a new tool.

import { Context, Schema } from "effect"
import { Tool } from "effect/unstable/ai"
class Db extends Context.Service<Db, { query: () => string }>()("Db") {}
const Search = Tool.make("Search")
.setParameters(Schema.Struct({ q: Schema.String }))
.setSuccess(Schema.Array(Schema.String))
.addDependency(Db) // handler can now use the Db service
.annotate(Tool.Readonly, true)

Creates a tool whose schema may not be known at compile time. Pass an Effect Schema for full type safety, or a raw JSON Schema (handler then receives unknown). Useful for MCP tools discovered at runtime or plugin systems.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
// Typed via Effect Schema
const Calculator = Tool.dynamic("Calculator", {
parameters: Schema.Struct({ a: Schema.Number, b: Schema.Number }),
success: Schema.Number
})
console.log(Calculator.jsonSchema) // => undefined (an Effect Schema was used)
// Untyped via raw JSON Schema
const McpTool = Tool.dynamic("McpTool", {
description: "Tool from MCP server",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
})
console.log(McpTool.jsonSchema?.type) // => "object"

Models a tool built into a provider (web search, code execution). Takes an id ("provider.name"), a customName used by the toolkit, the provider’s providerName, optional args/parameters/success/failure schemas, and requiresHandler. It returns a function: call it with the configuration args to get the tool instance.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const WebSearch = Tool.providerDefined({
id: "openai.web_search",
customName: "OpenAiWebSearch",
providerName: "web_search",
args: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ title: Schema.String, url: Schema.String })
})({ query: "effect-ts" }) // apply the args to build the instance
console.log(WebSearch.providerName) // => "web_search"
console.log(WebSearch.name) // => "OpenAiWebSearch"

Returns true for tools created with Tool.make (not provider-defined, not dynamic).

import { Tool } from "effect/unstable/ai"
console.log(Tool.isUserDefined(Tool.make("A"))) // => true

Returns true for tools created with Tool.providerDefined.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const Web = Tool.providerDefined({
id: "openai.web_search",
customName: "Web",
providerName: "web_search",
args: Schema.Struct({ query: Schema.String })
})({ query: "x" })
console.log(Tool.isProviderDefined(Web)) // => true
console.log(Tool.isProviderDefined(Tool.make("A"))) // => false

Returns true for tools created with Tool.dynamic.

import { Tool } from "effect/unstable/ai"
const D = Tool.dynamic("D", { parameters: { type: "object", properties: {} } })
console.log(Tool.isDynamic(D)) // => true
console.log(Tool.isDynamic(Tool.make("A"))) // => false

The string union "error" | "return" controlling whether handler failures go to the effect’s error channel ("error", default) or are returned as the tool result ("return"). Set via the failureMode option on make / dynamic / providerDefined.

The type of the needsApproval option: either a static boolean or a NeedsApprovalFunction. true always requires approval; false/undefined executes immediately.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const Wipe = Tool.make("Wipe", {
parameters: Schema.Struct({ confirm: Schema.Boolean }),
needsApproval: true // boolean form of NeedsApproval
})

A function (params, context) => boolean | Effect<boolean> that decides approval per call from the decoded parameters and a NeedsApprovalContext.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const SendEmail = Tool.make("SendEmail", {
parameters: Schema.Struct({ to: Schema.String, body: Schema.String }),
needsApproval: (params, _context) => params.to.endsWith("@external.com")
})

The second argument to a NeedsApprovalFunction: { toolCallId: string, messages: ReadonlyArray<Prompt.Message> }. Inspect the conversation when deciding whether to require approval.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const RunQuery = Tool.make("RunQuery", {
parameters: Schema.Struct({ sql: Schema.String }),
// Require approval once the conversation grows long.
needsApproval: (_params, ctx) => ctx.messages.length > 10
})

Annotations attach metadata via Context references on tool.annotations. Set them with .annotate(tag, value). Most map to MCP hints; note the defaults below.

A human-readable title for the tool (MCP title).

import { Tool } from "effect/unstable/ai"
const t = Tool.make("calculate_tip").annotate(Tool.Title, "Tip Calculator")

Arbitrary metadata record exposed to MCP clients (e.g. UI hints).

import { Tool } from "effect/unstable/ai"
const t = Tool.make("calculator_ui")
.annotate(Tool.Meta, { ui: { resourceUri: "ui://example/calculator-ui" } })

Whether the tool only reads data (MCP readOnlyHint). Default false.

import { Context } from "effect"
import { Tool } from "effect/unstable/ai"
const t = Tool.make("get_user_info").annotate(Tool.Readonly, true)
console.log(Context.get(t.annotations, Tool.Readonly)) // => true

Whether the tool may perform destructive operations (MCP destructiveHint). Default true, so annotate safe tools with false.

import { Tool } from "effect/unstable/ai"
const t = Tool.make("search_database").annotate(Tool.Destructive, false)

Whether repeated calls with the same parameters have no further effect (MCP idempotentHint). Default false.

import { Tool } from "effect/unstable/ai"
const t = Tool.make("get_current_time").annotate(Tool.Idempotent, true)

Whether the tool interacts with arbitrary external systems (MCP openWorldHint). Default true.

import { Tool } from "effect/unstable/ai"
const t = Tool.make("internal_operation").annotate(Tool.OpenWorld, false)

Per-tool override for strict JSON Schema mode. true/false force the provider’s strict flag; undefined (default) defers to provider/global config.

import { Tool } from "effect/unstable/ai"
const t = Tool.make("search").annotate(Tool.Strict, false)

Reads the Strict annotation; returns undefined when no per-tool override is set (distinct from false).

import { Tool } from "effect/unstable/ai"
const t = Tool.make("search").annotate(Tool.Strict, false)
console.log(Tool.getStrictMode(t)) // => false
console.log(Tool.getStrictMode(Tool.make("other"))) // => undefined

Returns the tool’s description, falling back to the parameter schema’s description annotation if none was set explicitly.

import { Tool } from "effect/unstable/ai"
const t = Tool.make("example", { description: "This is an example tool" })
console.log(Tool.getDescription(t)) // => "This is an example tool"

Generates the provider-facing JSON Schema for a tool’s parameters. For dynamic tools built from raw JSON Schema it returns that schema directly. Accepts an optional { transformer } for provider-specific shaping.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const weather = Tool.make("get_weather", {
parameters: Schema.Struct({ location: Schema.String })
})
console.log(Tool.getJsonSchema(weather))
// => { type: "object", properties: { location: { type: "string" } }, required: ["location"], ... }

Generates JSON Schema directly from any Effect Schema (the lower-level building block behind getJsonSchema).

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
console.log(Tool.getJsonSchemaFromSchema(Schema.Struct({ n: Schema.Number })))
// => { type: "object", properties: { n: { type: "number" } }, required: ["n"], ... }

Maps between a tool’s custom (Effect) name and its provider name, so toolkits can hold similarly named native tools from different providers without conflict.

import { Schema } from "effect"
import { Tool } from "effect/unstable/ai"
const Web = Tool.providerDefined({
id: "openai.web_search",
customName: "OpenAiWebSearch",
providerName: "web_search",
args: Schema.Struct({ query: Schema.String })
})({ query: "x" })
const mapper = new Tool.NameMapper([Web])
console.log(mapper.getProviderName("OpenAiWebSearch")) // => "web_search"
console.log(mapper.getCustomName("web_search")) // => "OpenAiWebSearch"

The schema used for tools that take no parameters — Schema.Record(Schema.String, Schema.Never), i.e. an empty object. Tool.make uses it automatically when parameters is omitted.

import { Tool } from "effect/unstable/ai"
const t = Tool.make("ping", { parameters: Tool.EmptyParams })
console.log(Tool.getJsonSchema(t).type) // => "object"

Parses JSON text while rejecting prototype-pollution keys (__proto__, dangerous constructor.prototype). Throws SyntaxError on invalid JSON or unsafe shapes.

import { Tool } from "effect/unstable/ai"
console.log(Tool.unsafeSecureJsonParse(`{"a":1}`)) // => { a: 1 }
// Tool.unsafeSecureJsonParse(`{"__proto__":{}}`) throws SyntaxError

The module also exports type-level helpers for extracting parts of a tool’s definition. These exist only at the type level (no runtime value):

  • Tool.Name<T> — the tool’s name literal.
  • Tool.Parameters<T> — the decoded parameter type (Encoded variant: ParametersEncoded<T>; schema: ParametersSchema<T>).
  • Tool.Success<T> — the success type (SuccessEncoded, SuccessSchema).
  • Tool.Failure<T> — the failure type (FailureEncoded, FailureResult, FailureResultEncoded).
  • Tool.Result<T>Success | Failure (plus AiError when failureMode: "return"); encoded variant ResultEncoded<T>.
  • Tool.Handler<Name> / Tool.HandlerResult<T> / Tool.HandlerError<T> / Tool.HandlerServices<T> — handler-related shapes.
  • Tool.HandlersFor<Tools> — the union of handler requirements for a tool record.
  • Tool.RequiresHandler<T>true unless the tool is provider-defined with requiresHandler: false.

The Toolkit module groups tools into a typed service that executes calls by name.

Builds a toolkit from any number of tools. Tool names become the lookup keys.

import { Schema } from "effect"
import { Tool, Toolkit } from "effect/unstable/ai"
const SearchDocs = Tool.make("SearchDocs", {
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Array(Schema.String)
})
const kit = Toolkit.make(SearchDocs)
console.log(Object.keys(kit.tools)) // => ["SearchDocs"]

An empty toolkit — a useful starting point or default that you extend with merge.

import { Toolkit } from "effect/unstable/ai"
console.log(Object.keys(Toolkit.empty.tools)) // => []

Combines multiple toolkits into one. On name conflicts, tools from later toolkits win.

import { Schema } from "effect"
import { Tool, Toolkit } from "effect/unstable/ai"
const math = Toolkit.make(
Tool.make("add", { success: Schema.Number }),
Tool.make("subtract", { success: Schema.Number })
)
const util = Toolkit.make(Tool.make("get_time", { success: Schema.Number }))
const combined = Toolkit.merge(math, util)
console.log(Object.keys(combined.tools)) // => ["add", "subtract", "get_time"]

A toolkit is itself an Effect: yielding it produces a WithHandler value once handlers have been provided via toLayer / toHandlers. The handler record must match HandlersFrom — one function per tool that requires a handler.

import { Effect, Schema, Stream } from "effect"
import { Tool, Toolkit } from "effect/unstable/ai"
const GetWeather = Tool.make("GetWeather", {
parameters: Schema.Struct({ location: Schema.String }),
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String })
})
const WeatherToolkit = Toolkit.make(GetWeather)
const WeatherLayer = WeatherToolkit.toLayer({
GetWeather: ({ location }) =>
Effect.succeed({ temperature: 72, condition: `sunny in ${location}` })
})
const program = Effect.gen(function*() {
const toolkit = yield* WeatherToolkit
// `handle` validates params, runs the matching handler, and returns a Stream
// of preliminary + final results.
const stream = yield* toolkit.handle("GetWeather", { location: "SF" })
const results = yield* Stream.runCollect(stream)
return Array.from(results, ({ result }) => result)
}).pipe(Effect.provide(WeatherLayer))
console.log(Effect.runSync(program))
// => [{ temperature: 72, condition: "sunny in SF" }]

toLayer(build) returns a Layer providing the handlers. toHandlers(build) returns an Effect producing the raw handler Context instead — use it when you need the context value directly. Both accept either a handler record or an Effect that produces one.

A type-only identity helper for declaring handler records with full inference of parameter and result types.

import { Effect } from "effect"
// Using WeatherToolkit from above:
const handlers = WeatherToolkit.of({
GetWeather: ({ location }) =>
Effect.succeed({ temperature: 70, condition: `clear in ${location}` })
})

The shape produced by yielding a live toolkit: { tools, handle }. handle(name, params) validates the parameters, runs the matching handler, and returns an Effect of a Stream of HandlerResult values (so handlers can emit progress before the final result). A missing tool fails with an AiError.

The second argument passed to each handler: { preliminary }. Call context.preliminary(value) to stream an intermediate result before the handler returns its authoritative final value.

import { Effect, Schema } from "effect"
import { Tool, Toolkit } from "effect/unstable/ai"
const LongJob = Tool.make("LongJob", { success: Schema.String })
const layer = Toolkit.make(LongJob).toLayer({
LongJob: (_params, context) =>
Effect.gen(function*() {
yield* context.preliminary("working...") // intermediate progress
return "done" // final, authoritative result
})
})

Toolkit-level type utilities (type level only):

  • Toolkit.Tools<T> — extract the tool record from a toolkit.
  • Toolkit.ToolsByName<Tools> — normalize a tool array/record into a name-keyed record (what make uses internally).
  • Toolkit.HandlersFrom<Tools> — the required handler record shape; handlers may fail with the tool’s failure, an AiError, or an AiErrorReason.
  • Toolkit.MergedTools<Toolkits> — the merged tool record of several toolkits (what merge produces).
  • Toolkit.WithHandlerTools<T> — extract the tool record from a WithHandler.
  • Language Model — the generateText API tools plug into.
  • Chat — drive an agentic tool-calling loop over a stateful session.
  • Schema — define tool parameters and results.