Migrating to 0.9
0.9 reworks the tool layer. Two breaking changes, both mechanical at the call site:
- A
Toolkitis now a name-indexed record thatToolkit.runandstreamTurntake directly, not a bare tool array. - Plain and streaming tools are one shape:
Tool.makewhoserun(input, emit)returns anEffect.Tool.streamingandfinalizeare 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.
| Before | After |
|---|---|
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.
// Beforeconst 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)),)
// Afterconst 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>.
// BeforeTool.streaming({ name, description, inputSchema, run: (input) => sourceStream(input), finalize: (events) => reduce(events),})
// AfterTool.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.
// Beforeexport const escalate = Tool.make({ name: "escalate", description, inputSchema: Tool.fromEffectSchema(EscalateInput), run: () => Effect.succeed({ escalated: true }),})
// Afterexport 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.Failurekind"input_validation_error"(was bucketed under"execution_error"), andTool.decodeArgsfails with the newTool.ToolValidationErrorinstead of a genericToolError. - A non-local kind (provider / signal / interaction) passed to
Toolkit.runyieldsToolResult.Failurekind"non_local_tool"(the loop is meant to intercept it first), distinct from"unknown_tool". Toolkit.makerejects 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
LanguageModeltag, 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
- Replace tool arrays with
Toolkit.make(...); pass the toolkit tostreamTurnandToolkit.run; drop theTool.toDescriptors(...)call at the request site. - Convert
Tool.streaming({ run, finalize })toTool.make({ run: (input, emit) => ... }), folding events into the output insiderun. - Switch faked control tools (
run: () => succeed) toTool.signal/Tool.interaction, and hand-built provider descriptors toTool.provider. - Combine cross-source toolkits with
Toolkit.composeinstead of array concatenation. - Run
pnpm typecheck.
See the Tools and toolkits concept page for the full, current picture.