| name | effect-uai-migrate |
| description | Use when the user is upgrading effect-uai across versions, sees compile errors after a version bump, or asks Claude to "update my effect-uai code to the latest". Encodes per-version rename tables and behavior changes so Claude can rewrite call sites mechanically without re-reading the changelog each time. |
| license | MIT |
effect-uai migrate
Use this skill when the user is upgrading from one effect-uai release
to a newer one. It contains the consolidated rename and removal rules
for each release, in the form Claude needs to apply rewrites:
"if you see X, write Y, here's the why."
Reach for this when the user says any of:
- "I bumped effect-uai and everything broke"
- "Update my code to the latest effect-uai"
- "What changed in 0.5?"
- "How do I migrate from 0.4 to 0.5?"
How to use this skill
- Identify the source version (look at
package.json or ask).
- Walk the version tables below in order, applying each rewrite to
the user's code.
- After each rewrite, run typecheck (
pnpm typecheck or equivalent)
to confirm.
- Skip "optional" rewrites unless the user asks to modernize.
The full migration prose (with rationale and edge cases) lives in
docs/migrations/v{X.Y}.md. This skill is the operator-mode summary.
0.10 → 0.11
Additive, with one required action: bump the effect peer dependency. The rest
of the release is new capabilities, no renames.
Required: bump Effect
The effect peer dependency moves from the pin 4.0.0-beta.57 to the range
>=4.0.0-beta.94 <5.0.0. Update the user's app to effect@4.0.0-beta.94 or
newer, then run typecheck and fix any Effect-beta changes in their own code (the
betas move fast between .57 and .94). effect-uai's own API renamed nothing.
pnpm add effect@">=4.0.0-beta.94 <5.0.0"
Additive (no migration needed)
DeepResearch: import { research, researchStream } from "@effect-uai/core/DeepResearch"
(request { history, model? }). A completed job is a plain Turn
(Turn.assistantText / Turn.citations / Turn.decodeStructured);
researchStream terminates in TurnComplete. Detached path:
submit / status / collect / streamFrom / cancel over a serializable
ResearchJobRef. Providers register the generic DeepResearch tag plus a
provider-typed tag: @effect-uai/responses/OpenAIDeepResearch,
@effect-uai/google/GoogleDeepResearch,
@effect-uai/perplexity/PerplexityDeepResearch,
@effect-uai/exa/ExaDeepResearch. @effect-uai/core/Job and
@effect-uai/core/Citation ship alongside.
- Native grounding (provider tools): put a provider-hosted tool in a
Toolkit next to function tools and the adapter renders it to the native
tools entry. @effect-uai/responses/ResponsesTools (webSearchTool,
codeInterpreterTool, fileSearchTool), @effect-uai/google/GeminiTools
(googleSearchTool, urlContextTool, codeExecutionTool),
@effect-uai/anthropic/AnthropicTools (webSearchTool, codeExecutionTool).
New core helpers Tool.isProviderTool / Tool.providerToolsOf. An unrenderable
provider tool fails AiError.Unsupported.
@effect-uai/ai-sdk: Vercel AI SDK compatibility.
Messages.decodeMessages (UIMessage[] to HistoryItem[]) and
UIMessageStream.toUIMessageStream (loop InteractionEvents to AI SDK UI
Message Stream SSE.Events). From 0.11 it is fixed to the shared version.
Behavior changes (no rewrite)
Items.UrlCitation widened: start_index / end_index are now optional and
cited_text / marker were added. Reads become number | undefined.
Tool.make / Tool.provider accept an Effect Schema directly as
inputSchema (no fromEffectSchema wrap needed; still supported).
Toolkit.namespace preserves a tool's E and R through the prefix rewrite.
SSE / JSONL decoders are backed by Effect unstable/encoding.
- Mistral no longer synthesizes
TurnComplete for a truncated / failed stream.
After-migration checklist
0.9 → 0.10
Mostly additive. One breaking change to the tool layer: a Tool now carries a
typed error E, and Toolkit.run propagates tool failures typed instead of
silently folding them into a model-visible result. Plus two additive
capabilities: WebRead (URL to clean markdown, four providers) and Browser
(drive a real browser over CDP).
Required rewrites
Tools carry a typed error E
run's error channel goes from unknown to a typed E, inserted before R.
| Before | After |
|---|
Tool<Name, Input, Event, Output, R> | Tool<Name, Input, Event, Output, E, R> |
Tool.make infers E from run, so tools built with it need no change. Only a
hand-written full Tool<...> annotation breaks: a requirement that used to
sit in the fifth slot now lands in the error slot. Add unknown (or the real
error) as the fifth argument and shift R to sixth. New Tool.ToolE<T>
extractor exists alongside Tool.ToolR<T>.
Failed tools propagate; opt them back into the model
Toolkit.run now splits tool failures two ways:
- A
string, or Tool.fail(message, { kind? }) (fails with the ToolFailed
sentinel), is absorbed into a ToolResult.Failure the model reads.
- Every other tool error stays typed on the channel and propagates
(
Exclude<..., string | ToolFailed>); the loop handles it (catchTag,
fallback, or fail). Defects die.
If you relied on arbitrary tool failures reaching the model, either fail with a
string / Tool.fail(...), or wrap the toolkit:
Toolkit.describeFailures(describe) maps its failures to model-visible strings
(built on 0.9's Toolkit.wrap).
Behavior changes (no rewrite)
Approval.fromQueue takes an optional timeout (unanswered gated calls
resolve cancelled); its router retires once a round is resolved.
- Tool input decoding hardened: empty args normalize to
{}, a throwing
validator is caught, bad args return a ToolResult.Failure kind
input_validation_error.
Additive (no migration needed)
WebRead: import { read } from "@effect-uai/core/WebRead" (request
{ url, format?, timeout? }), webReadTool from
@effect-uai/core/WebReadTool. Providers register the generic WebRead
tag: @effect-uai/firecrawl (new), @effect-uai/exa/ExaContents,
@effect-uai/tavily/TavilyRead, @effect-uai/jina/JinaReader.
Browser: @effect-uai/core/Browser tag + @effect-uai/core/BrowserTool
(gotoTool / clickTool / fillTool / pressTool / scrollTool,
browserToolkit(session)). Provider @effect-uai/browser/Connect layer({ endpoint }) connects to any CDP WebSocket (Chromium container, local
Chrome/Edge, obscura, hosted browser cloud).
After-migration checklist
0.8 → 0.9
A tool-layer refactor. Two changes: (1) a Toolkit is now a name-indexed record
that Toolkit.run takes directly (not a bare array), and (2) plain and streaming
tools are unified into one Tool.make whose run(input, emit) returns an Effect
(Tool.streaming / finalize are gone). Mostly mechanical at call sites.
Required rewrites
Toolkit is a record; build it with Toolkit.make
Toolkit.run, and the 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) |
streamTurn({ tools: descriptors }) | streamTurn({ tools: toolkit }) |
streamTurn({ tools: [] }) | streamTurn({ /* omit tools */ }) |
Toolkit.run(tools, calls) | Toolkit.run(toolkit, calls) |
streamTurn's tools? now takes the Toolkit directly (it renders descriptors
at the provider boundary), so the Toolkit.descriptors(toolkit) /
Tool.toDescriptors([...]) call at the request site is gone — pass toolkit, and
omit tools entirely for a turn with none. Toolkit.descriptors still exists if
you want the ToolDescriptor[] yourself.
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, trusted/last-wins).
Combine independent toolkits with Toolkit.compose(...kits)
(effectful; fails DuplicateToolName with source provenance, compile error for
static clashes), prefixing generic names first with Toolkit.namespace(prefix, kit)
when needed. A function that takes a toolkit as a parameter types it as
Toolkit.Toolkit.
run(input, emit); Tool.streaming / finalize removed
Plain and streaming tools are one shape. run returns the model-facing Output
as an Effect and calls emit(event) for progress (it composes with
Stream.runForEach / Stream.runFoldEffect). There is no finalize — fold the
events into the output inside run. Tool.streaming, StreamingTool,
isStreamingTool, AnyStreamingTool, and AnyPlainTool are removed; AnyTool
gains an Event type param (Tool<Name, Input, Event, Output, R>).
Tool.streaming({
name,
description,
inputSchema,
run: (input) => sourceStream(input),
finalize: (events) => reduce(events),
})
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)),
),
})
Canonical loop body
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)),
)
const toolkit = Toolkit.make(getTime, lookupWeather)
lm.streamTurn({ history, model, tools: toolkit })
return Toolkit.run(toolkit, calls).pipe(
Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
)
Control / 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(...) (the loop intercepts the call
in onTurnComplete) should become a Tool.signal; an "ask the user/channel"
tool a Tool.interaction. Both are decode-only — keep Tool.decodeArgs — they
just drop the fake run.
export const escalate = Tool.make({
name: "escalate",
description,
inputSchema: Tool.fromEffectSchema(EscalateInput),
run: () => Effect.succeed({ escalated: true }),
})
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 }).
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 now rejects a duplicate literal tool name at compile time. It
does not otherwise validate names (a malformed name 400s at the provider).
After-migration checklist
New: Mistral provider (additive, no rewrite)
0.9 also ships @effect-uai/mistral, one brand covering three capability
tags: LanguageModel (Mistral chat models), Transcriber (Voxtral batch +
realtime STT), and SpeechSynthesizer (Voxtral TTS). Adopt it the same way
as any provider: provide the layer, keep yielding the generic tag.
import { layer as mistral } from "@effect-uai/mistral/Mistral"
Because all three surfaces share one brand, the
Voice loop runs an
entire STT to LLM to TTS pipeline on Mistral alone (--provider=mistral).
Metrics reworked (one removal, rest additive)
@effect-uai/core/Metrics drops the old generic stream helpers
withElapsed, timeToFirst, and withRate. Replace them with the new
turn-aware operators: timeToFirstToken, throughput, tokenTotals,
timeToCompletion, or allMetrics() to stack all four. They emit typed
MetricEvents alongside the model's events; split them out downstream with
isMetricEvent. The new @effect-uai/core/Telemetry adds record() plus
layerOtlp({ url }) to export the same events over OTLP. See the
Metrics concept page.
0.7 → 0.8
No rewrites needed. 0.8 is purely additive: a new WebSearch
capability in @effect-uai/core (generic WebSearch service + search
helper + webSearchTool), and three search providers
(@effect-uai/perplexity, @effect-uai/exa, @effect-uai/tavily).
Nothing in the existing surface changed. Bump dependencies, run typecheck,
done.
If the user sees a 0.8-version compile error that looks like a rename
(durationSeconds, GeminiTranscriber, prompts, bpm, etc.), they are
actually on 0.6 or older, so apply the 0.6 → 0.7 rules below (and the
earlier sections) first.
New-code patterns (only if the user is adopting search)
import { search } from "@effect-uai/core/WebSearch"
const { results } = yield * search({ query: "..." })
import { webSearchTool } from "@effect-uai/core/WebSearchTool"
const tools = [webSearchTool({ maxResults: 5 })]
import { layer as perplexity } from "@effect-uai/perplexity/PerplexitySearch"
The full tour is in
Migrating to 0.8 and
the Web search overview.
0.6 → 0.7
A capability-honesty pass across audio and embeddings. Three flavors of
change: (1) mechanical renames (mostly durationSeconds → duration),
(2) one removed module (GeminiTranscriber), (3) requests that now fail
AiError.Unsupported or warnDropped where 0.6 degraded silently. The
type renames are find-and-replace; the silent-to-error changes need a
judgement call per call site (drop the field, switch provider, or handle
the new error).
Required rewrites
Removed: GeminiTranscriber
@effect-uai/google/GeminiTranscriber is deleted (it was an LLM with a
"transcribe" prompt, not a real STT endpoint). No drop-in replacement;
switch to a real transcription provider.
import * as GeminiTranscriber from "@effect-uai/google/GeminiTranscriber"
import * as OpenAITranscriber from "@effect-uai/openai/OpenAITranscriber"
GeminiTranscribeRequest and GeminiSttModel are gone with it.
durationSeconds: number → duration: Duration.Duration
Applies to AudioBlob (TTS / music output) and TranscriptResult (STT).
Per-word offsets (WordTimestamp.startSeconds / endSeconds) stay raw
number.
const secs = blob.durationSeconds
import { Duration } from "effect"
const secs = blob.duration ? Duration.toSeconds(blob.duration) : undefined
Flat renames / removals
| Before | After |
|---|
AudioBlob.durationSeconds / TranscriptResult.durationSeconds | .duration (Duration.Duration) |
transcribe({ prompt: { terms: [...] } }) | transcribe({ biasingTerms: [...] }) |
CustomPronunciation { …, encoding: "ipa" } | { phrase, pronunciation } (drop encoding, IPA-only) |
PhoneticEncoding (type) | removed (no replacement) |
DialogueTurn { …, styleDescription, speed } | { voiceId, text } (drop the two extra fields) |
EmbedEncoding = … "sparse" | "multivector" | "float32" | "int8" | "binary" (use JinaEncoding for the rest) |
embed({ encoding: "sparse" | "multivector" }) (generic) | JinaEmbedding.asEffect() then jina.embed({ encoding }) |
prompt?: string still exists on CommonTranscribeRequest (prose
context). Only the { terms } union arm moved to biasingTerms.
Behavior changes (no rewrite, but observable)
Apply only if the call site relied on the old silent behavior or matches
on the error tag:
- Embeddings, generic path: a non-
float32 encoding on OpenAI /
Gemini (and scalar int8 on Jina) now fails Unsupported instead of
returning a mislabeled float32. Omit encoding or pass "float32".
- Embeddings: OpenAI image input and Jina multi-part input now fail
Unsupported (were InvalidRequest). OpenAI task now warnDropped.
- TTS:
pronunciations now fails Unsupported on OpenAI / Gemini /
modern ElevenLabs (no IPA path). Drop them, switch to Inworld, or use
ElevenLabs pronunciationDictionaryLocators.
- STT: stream
inputFormat gaps now fail Unsupported (were
InvalidRequest). OpenAI no longer accepts diarization on its typed
request, and a non-whisper-1 wordTimestamps request now surfaces the
wire 400 rather than a pre-send Unsupported.
- LLM: Gemini
toolChoice is now applied (was forced to AUTO and
ignored). Gemini url-source images now fail Unsupported (were
silently dropped); pass base64 / bytes.
After-migration checklist
0.5 → 0.6
The "consistent naming" sweep (0.6 also adds multi-speaker dialogue and
custom pronunciations on SpeechSynthesizer — additive, no rewrites).
Almost entirely find-and-replace; the only judgement call is the Loop
helper trim. Wire literals ("function_call", "function_call_output")
do not change — only type and helper names do, so provider payloads
and on-the-wire pattern matching stay identical.
Required rewrites
Module moves
| Before | After |
|---|
@effect-uai/core/Outcome | @effect-uai/core/ToolResult |
@effect-uai/core/Resolvers | @effect-uai/core/Approval |
Flat renames
| Before | After |
|---|
Item | HistoryItem |
FunctionCall | ToolCall |
FunctionCallOutput | ToolCallOutput |
Items.functionCallOutput(…) | Items.toolCallOutput(…) |
Items.isFunctionCall | Items.isToolCall |
Items.isFunctionCallOutput | Items.isToolCallOutput |
Turn.functionCalls(turn) | Turn.getToolCalls(turn) |
Turn.appendTurn(…) | Turn.appendToHistory(…) |
Turn.toStructured(…) | Turn.decodeStructured(…) |
ToolResult.Value / isValue | ToolResult.Ok / isOk |
rejected(call, kind, reason) | failed(call, kind, reason) |
toFunctionCallOutput(…) | toToolCallOutput(…) |
Toolkit.executeAll(…) | Toolkit.run(…) |
Toolkit.continueWith(…) | Toolkit.continueWithResults(…) |
Tool.AnyKindTool | Tool.AnyTool |
ToolEvent.Intermediate / isIntermediate | ToolEvent.Progress / isProgress |
Loop.loopFrom(…) | Loop.loopOver(…) |
Loop.Event<A, S> | Loop.Step<A, S> |
ToolCallDecision | ApprovalDecision |
fromApprovalMap(…) | fromMap(…) |
fromVerdictQueue(…) | fromQueue(…) |
fromQueue(…).announce | fromQueue(…).approvalRequests |
The Failure variant, the denied / cancelled / executionError
synthesizers, approve / reject, and the approved / decisions
fields keep their names.
Removed: Toolkit.make / Toolkit.toDescriptors
Build a flat array of tools (plain, streaming, or mixed) and render it
with Tool.toDescriptors.
const toolkit = Toolkit.make([getTime, lookupWeather])
const descriptors = Toolkit.toDescriptors(toolkit)
import * as Tool from "@effect-uai/core/Tool"
const descriptors = Tool.toDescriptors([getTime, lookupWeather])
Trimmed Loop helpers: stop / next are streams, *After gone
next(state), stop(), and stop(state) each emit a single terminal
step. Concatenate your values in front of them; stopWith(state)
collapses into stop(state).
| Before | After |
|---|
return stop | return stop() |
return stopWith(state) | return stop(state) |
return nextAfter(stream, s) | return stream.pipe(Stream.map(value), Stream.concat(next(s))) |
return stopAfter(stream) | return stream.pipe(Stream.map(value), Stream.concat(stop())) |
return stopWithAfter(stream, s) | return stream.pipe(Stream.map(value), Stream.concat(stop(s))) |
stopEvent and nextAfterFold are removed with no direct replacement —
build the step stream from value / next / stop plus standard
Stream combinators.
Canonical loop body
onTurnComplete<State, ToolEvent>((turn) => {
const calls = Turn.functionCalls(turn)
if (calls.length === 0) return stop
return Toolkit.executeAll(tools, calls).pipe(
Toolkit.continueWith((results) =>
Turn.appendTurn(state, turn, results.map(toFunctionCallOutput)),
),
)
})
onTurnComplete<State, ToolEvent>((turn) => {
const calls = Turn.getToolCalls(turn)
if (calls.length === 0) return stop()
return Toolkit.run(tools, calls).pipe(
Toolkit.continueWithResults((results) =>
Turn.appendToHistory(state, turn, results.map(toToolCallOutput)),
),
)
})
After-migration checklist
0.4 → 0.5
Required rewrites
Reshape: TurnEvent is a Data.TaggedEnum
Discriminator renamed type → _tag; variants snake_case → PascalCase.
| Before | After |
|---|
{ type: "text_delta", text } | TurnEvent.TextDelta({ text }) |
{ type: "reasoning_delta", text, kind } | TurnEvent.ReasoningDelta({ text, kind }) |
{ type: "refusal_delta", text } | TurnEvent.RefusalDelta({ text }) |
{ type: "tool_call_start", call_id, name } | TurnEvent.ToolCallStart({ call_id, name }) |
{ type: "tool_call_args_delta", call_id, delta } | TurnEvent.ToolCallArgsDelta({ call_id, delta }) |
{ type: "usage_update", usage } | TurnEvent.UsageUpdate({ usage }) |
{ type: "turn_complete", turn } | TurnEvent.TurnComplete({ turn }) |
import type { TurnEvent } from "@effect-uai/core/Turn"
if (event.type === "turn_complete") use(event.turn)
Match.value(event).pipe(
Match.discriminators("type")({ text_delta: ..., turn_complete: ... }),
Match.exhaustive,
)
import { TurnEvent } from "@effect-uai/core/Turn"
if (event._tag === "TurnComplete") use(event.turn)
Match.value(event).pipe(
Match.discriminators("_tag")({ TextDelta: ..., TurnComplete: ... }),
Match.exhaustive,
)
Turn.isTurnComplete and Turn.textDeltas still work — they were
updated internally.
Within 0.5.x: 0.5.0/0.5.1 → 0.5.2
Two breaking renames. Mechanical.
LanguageModel.retry → Retry.stream
import { retry, Retryable } from "@effect-uai/core/LanguageModel"
streamTurn(req).pipe(retry(schedule))
import * as Retry from "@effect-uai/core/Retry"
streamTurn(req).pipe(Retry.stream(schedule))
embed(req).pipe(Retry.effect(schedule))
Retryable / isRetryable move to the same module.
Hand-rolled LanguageModelService needs a turn field
turn is now on the service alongside streamTurn. Provider layers
and MockProvider are fine; custom test services need both fields:
const service: LanguageModelService = {
streamTurn: () => ...,
}
import { turnFromStream } from "@effect-uai/core/LanguageModel"
const streamTurn: LanguageModelService["streamTurn"] = () => ...
const service: LanguageModelService = { streamTurn, turn: turnFromStream(streamTurn) }
Continuing the 0.4 → 0.5 list
Reshape: ToolCallDecision is a Data.TaggedEnum
const d: ToolCallDecision = { _tag: "Approved", call }
import { ToolCallDecision } from "@effect-uai/core/Resolvers"
const d = ToolCallDecision.Approved({ call })
const d = Resolvers.approve(call)
Removed: Toolkit.outputEvent / Toolkit.outputEvents
import { outputEvent, outputEvents } from "@effect-uai/core/Toolkit"
outputEvent(result)
outputEvents(results)
import { ToolEvent } from "@effect-uai/core/ToolEvent"
import { Stream } from "effect"
ToolEvent.Output({ result })
Stream.fromIterable(results.map((result) => ToolEvent.Output({ result })))
Rename: Encoding → EmbedEncoding
import type { Encoding } from "@effect-uai/core/EmbeddingModel"
import type { EmbedEncoding } from "@effect-uai/core/EmbeddingModel"
Avoids the clash with Effect's Encoding module. Provider-typed
unions (JinaEncoding, etc.) are unchanged.
Optional modernizations
EmbedResponse<E> is now generic
Type-level reshape — runtime behavior unchanged.
const { embedding } = yield * embed({ model, input, encoding: "float32" })
if (embedding._tag !== "float32") return
embedding.vector
const { embedding } = yield * embed({ model, input, encoding: "float32" })
embedding.vector
Bare EmbedResponse still works (defaults to Float32Embedding).
Loop.stopWith(state) for loopFrom / loopWithState
Terminal event that ends the loop AND carries final state. Use it when
you want a clean "this input is done, here's the state to carry
forward" signal in loopFrom, or to capture the last state in
loopWithState's SubscriptionRef. Plain loop treats it like
stop.
Loop.loopFrom(input, initial, body)
Input-driven sibling of loop. For each item from input, runs an
inner seed-driven loop with (s) => body(s, item). State threads
across items via next / stopWith.
LanguageModel.turn(request) instead of draining manually
const events = yield * Stream.runCollect(streamTurn(request))
const turn = events.findLast(Turn.isTurnComplete)?.turn
const turn = yield * LanguageModel.turn(request)
Retry.stream(schedule) / Retry.effect(schedule) for the retryable subset
import * as Retry from "@effect-uai/core/Retry"
const schedule = Schedule.exponential("200 millis").pipe(Schedule.compose(Schedule.recurs(3)))
streamTurn(request).pipe(Retry.stream(schedule))
embed(request).pipe(Retry.effect(schedule))
Turn.assistantText(turn) for the concatenated reply
const text = Turn.assistantText(turn)
const texts = Turn.assistantTexts(turn)
Tool.fromStandardSchema(schema) for Zod / Valibot / ArkType
const lookupWeather = Tool.make({
name: "lookup_weather",
inputSchema: Tool.fromStandardSchema(z.object({ city: z.string() })),
run: ({ city }) => ...,
})
Effect Schema users keep fromEffectSchema.
After-migration checklist
0.3 → 0.4
No rewrites needed. 0.4 is purely additive: new Transcriber /
SpeechSynthesizer / MusicGenerator services, shared Audio /
Transcript / Music domain, provider-fit markers (SttStreaming,
TtsIncrementalText), and three new provider packages
(@effect-uai/openai, @effect-uai/elevenlabs, @effect-uai/inworld).
Bump dependencies, run typecheck, done.
If the user sees a 0.4-version compile error that looks like a rename
(streamUntilComplete, nextStateFrom, matchType, etc.), they're
actually on 0.2 or older — apply the 0.2 → 0.3 rules below.
0.2 → 0.3
Required rewrites
Rename: streamUntilComplete → onTurnComplete
import { loop, stop, streamUntilComplete } from "@effect-uai/core/Loop"
stream.pipe(streamUntilComplete<State, ToolEvent>((turn) => ...))
import { loop, stop, onTurnComplete } from "@effect-uai/core/Loop"
stream.pipe(onTurnComplete<State, ToolEvent>((turn) => ...))
Pure rename. Replace the import and the call site. No behavior change.
Rename + reshape: Toolkit.nextStateFrom → Toolkit.continueWith
const events = Toolkit.executeAll(tools, calls)
return Toolkit.nextStateFrom(events, (results) =>
Turn.appendTurn(state, turn, results.map(toFunctionCallOutput)),
)
return Toolkit.executeAll(tools, calls).pipe(
Toolkit.continueWith((results) =>
Turn.appendTurn(state, turn, results.map(toFunctionCallOutput)),
),
)
continueWith is Function.dual-curried, so both forms type-check.
Use the pipe form unless events is assembled from multiple streams
(e.g. the queue-based approval flow), in which case keep the
intermediate const events =.
Removed: @effect-uai/core/Match / matchType
import { matchType } from "@effect-uai/core/Match"
const handler = matchType<ToolEvent>()({ Intermediate: ..., Output: ..., ApprovalRequested: ... })
import { Match } from "effect"
const handler = Match.discriminators("_tag")({ Intermediate: ..., Output: ..., ApprovalRequested: ... })
Use Match.discriminatorsExhaustive if exhaustiveness checking is
desired.
Optional modernizations
Tool requirements via R
If the user has tools that need API keys, DB handles, or other
services, they can now flow them via Effect's R channel:
class WeatherApiKey extends Context.Service<WeatherApiKey, { key: string }>()(
"app/WeatherApiKey",
) {}
const lookupWeather = Tool.make({
name: "lookup_weather",
inputSchema: ...,
run: ({ city }) =>
Effect.gen(function* () {
const { key } = yield* WeatherApiKey
return yield* fetchWeather(key, city)
}),
})
const events = Toolkit.executeAll([lookupWeather], calls)
events.pipe(Stream.provide(Layer.succeed(WeatherApiKey, { key: "..." })))
Pre-0.3 users typically captured services in closures or threaded
them through manually. They can keep doing that — the R channel is
opt-in.
Loop.loopWithState for post-loop state
const { stream, state } = yield* loopWithState(initial, body)
yield* Stream.runDrain(stream)
const final = yield* SubscriptionRef.get(state)
SubscriptionRef.changes(state).pipe(Stream.runForEach(...))
Use when callers need to inspect state after the loop drains, or
observe state transitions concurrently. Otherwise stay on loop.
Data.TaggedEnum constructors
ToolResult, ToolEvent, and Image*Source are now tagged enums.
Existing _tag literal pattern-matching and isValue / isFailure
predicates still work; the new shape is purely additive.
ToolResult.Failure({ call_id, tool, kind: "denied" })
ToolResult.$is("Failure")(result)
ToolResult.$match({ Value: ..., Failure: ... })(result)
After-migration checklist
0.1 → 0.2
(Not yet documented in this skill. See packages/core/CHANGELOG.md.)
When this skill should not run
- User is starting a new project — point them at
effect-uai-basic-usage.
- User is on the latest version and asking how a specific API works —
point them at the relevant feature skill (
effect-uai-tool-call-approval,
effect-uai-streaming-tool-output, etc.) or the docs.
- Breaking change is in user code, not in effect-uai — apply normal
Effect debugging.
See also