Skip to content

Migrating to 0.9

0.9 reworks the tool layer. Two breaking changes, both mechanical at the call site:

  1. A Toolkit is now a name-indexed record that Toolkit.run and streamTurn take directly, not a bare tool array.
  2. Plain and streaming tools are one shape: Tool.make whose run(input, emit) returns an Effect. Tool.streaming and finalize are gone.

It also adds new tool kinds (Tool.provider, Tool.signal, Tool.interaction), a way to compose toolkits from independent sources, and a new provider, @effect-uai/mistral. The provider and composition features are additive: skip those sections if you do not need them.

If you see a 0.9-version compile error that looks like an older rename (durationSeconds, executeAll, Outcome, Resolvers, etc.), you are upgrading across an earlier breaking release. Apply the 0.8 page and the ones before it first.

Required rewrites

Toolkit is a record; build it with Toolkit.make

Toolkit.run and descriptor rendering now go through a Toolkit value instead of a flat tool array.

BeforeAfter
const tools = [a, b]const toolkit = Toolkit.make(a, b)
Tool.toDescriptors(tools)(gone at the call site, see below)
streamTurn({ tools: descriptors })streamTurn({ tools: toolkit })
streamTurn({ tools: [] })streamTurn({ /* omit tools */ })
Toolkit.run(tools, calls)Toolkit.run(toolkit, calls)

streamTurn’s tools? takes the Toolkit directly and renders the wire descriptors at the provider boundary, so the Tool.toDescriptors([...]) / Toolkit.descriptors(toolkit) call at the request site is gone. Pass toolkit, and omit tools entirely for a turn with none. Toolkit.descriptors(toolkit) still exists if you want the ToolDescriptor[] yourself.

// Before
const tools = [getTime, lookupWeather]
const descriptors = Tool.toDescriptors(tools)
lm.streamTurn({ history, model, tools: descriptors })
// ...
return Toolkit.run(tools, calls).pipe(
Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
)
// After
const toolkit = Toolkit.make(getTime, lookupWeather)
lm.streamTurn({ history, model, tools: toolkit }) // toolkit straight in
// ...
return Toolkit.run(toolkit, calls).pipe(
Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
)

Toolkit.make(...tools) is variadic, indexes by tool.name, and rejects a duplicate literal name at compile time. Use Toolkit.fromArray(tools) for a runtime-built array (e.g. MCP, where names are trusted and last-wins). A function that accepts a toolkit parameter types it as Toolkit.Toolkit.

Plain and streaming tools unify: run(input, emit)

Tool.streaming, StreamingTool, isStreamingTool, AnyStreamingTool, and AnyPlainTool are removed. There is one tool shape. run returns the model-facing Output as an Effect and calls emit(event) for progress; fold the events into the output inside run (there is no finalize). The Tool type gains an Event parameter: Tool<Name, Input, Event, Output, R>.

// Before
Tool.streaming({
name,
description,
inputSchema,
run: (input) => sourceStream(input),
finalize: (events) => reduce(events),
})
// After
Tool.make({
name,
description,
inputSchema,
run: (input, emit) =>
sourceStream(input).pipe(
Stream.runFoldEffect(
() => init,
(acc, event) => emit(event).pipe(Effect.as(step(acc, event))),
),
Effect.map((acc) => reduce(acc)),
),
// optional: emitBufferSize bounds the emit queue (unbounded by default)
})

A plain tool just never calls emit. Events a tool emits surface to the consumer as ToolEvent.Progress; run still returns the single Output the model sees.

Control and provider tools get honest kinds

Tools now have four kinds, discriminated by _tag: Tool.make (local), Tool.provider, Tool.signal, Tool.interaction. A control tool you faked with a throwaway run: () => Effect.succeed(...), where the loop actually intercepts the call in onTurnComplete, becomes a Tool.signal. An “ask the user / channel and resume later” tool becomes a Tool.interaction. Both are decode-only: keep Tool.decodeArgs, just drop the fake run.

// Before
export const escalate = Tool.make({
name: "escalate",
description,
inputSchema: Tool.fromEffectSchema(EscalateInput),
run: () => Effect.succeed({ escalated: true }),
})
// After
export const escalate = Tool.signal({
name: "escalate",
description,
inputSchema: Tool.fromEffectSchema(EscalateInput),
})

A provider-hosted tool you passed as a hand-built descriptor becomes Tool.provider({ ..., provider, config }), which the provider adapter renders natively.

Behavior changes (no rewrite, but observable)

  • Input-schema validation failures now produce a distinct ToolResult.Failure kind "input_validation_error" (was bucketed under "execution_error"), and Tool.decodeArgs fails with the new Tool.ToolValidationError instead of a generic ToolError.
  • A non-local kind (provider / signal / interaction) passed to Toolkit.run yields ToolResult.Failure kind "non_local_tool" (the loop is meant to intercept it first), distinct from "unknown_tool".
  • Toolkit.make rejects a duplicate literal tool name at compile time. It does not otherwise validate names; a malformed name still 400s at the provider.

Composing toolkits (additive)

Combine independently-built toolkits (built-ins, MCP servers, signal sets) with Toolkit.compose. It is the boundary where names from separate sources can collide, so it is effectful: a duplicate final name fails with DuplicateToolName naming the colliding sources instead of silently overwriting. Prefix generic names first with Toolkit.namespace("github", kit) (or build them prefixed in one step with Toolkit.makeNamespaced("github", ...tools)).

const github = Toolkit.fromArray(githubTools)
const linear = Toolkit.fromArray(linearTools)
const toolkit = yield * Toolkit.compose(github, linear)

Toolkit.wrap(middleware) is a Toolkit -> Toolkit transform that wraps every local tool’s run (logging, retry, auth, metrics) and unions the middleware’s added requirement into the toolkit’s R.

What’s new: Mistral provider (additive)

0.9 adds @effect-uai/mistral, a single provider brand covering three surfaces:

  • Language model: Mistral chat models behind the generic LanguageModel tag, same as every other LLM provider.
  • Speech to text: Voxtral batch and realtime (streaming) transcription behind Transcriber.
  • Text to speech: Voxtral synthesis behind SpeechSynthesizer.

Nothing in your existing code changes. Provide the layer and your capability-tag code resolves.

import { layer as mistral } from "@effect-uai/mistral/Mistral"
const app = program.pipe(
Effect.provide(mistral({ apiKey: Redacted.make(process.env.MISTRAL_API_KEY!) })),
)

Because all three surfaces share one brand, you can run an entire speech-to-text to LLM to text-to-speech pipeline on Mistral alone. The Voice loop recipe ships this as a stack you select with --provider=mistral (the default stack stays ElevenLabs STT/TTS plus a Gemini LLM). See Mistral (LLM) and Mistral (Voxtral speech) for usage.

What’s new: streaming metrics (additive, one removal)

0.9 replaces the old generic stream helpers in @effect-uai/core/Metrics with turn-aware operators you stack onto a generation, plus an OTLP sink in the new @effect-uai/core/Telemetry. Metrics.withElapsed, Metrics.timeToFirst, and Metrics.withRate are removed; the new operators replace them.

import * as Metrics from "@effect-uai/core/Metrics"
const metered = LanguageModel.streamTurn(request).pipe(Metrics.allMetrics())

allMetrics stacks timeToFirstToken, throughput, tokenTotals, and timeToCompletion; pipe just the ones you want instead. They emit typed MetricEvents alongside the model’s own events. Separate them downstream with isMetricEvent, and mint your own with makeEvent. To export rather than log, record the same events with Telemetry.record() and provide Telemetry.layerOtlp({ url }). Nothing else in your code changes. See the Metrics concept page.

Migration order

  1. Replace tool arrays with Toolkit.make(...); pass the toolkit to streamTurn and Toolkit.run; drop the Tool.toDescriptors(...) call at the request site.
  2. Convert Tool.streaming({ run, finalize }) to Tool.make({ run: (input, emit) => ... }), folding events into the output inside run.
  3. Switch faked control tools (run: () => succeed) to Tool.signal / Tool.interaction, and hand-built provider descriptors to Tool.provider.
  4. Combine cross-source toolkits with Toolkit.compose instead of array concatenation.
  5. Run pnpm typecheck.

See the Tools and toolkits concept page for the full, current picture.