Migrating to 0.6
0.6 bundles one large-but-mechanical naming sweep with a set of additive
speech features. The breaking part is the naming sweep: the public API
settles on “tool call” terminology everywhere (no more “function call”
in type or helper names), two modules move to clearer names, and the
Loop helper surface is trimmed to value / next / stop. The
wire format is unchanged — function_call / function_call_output
still go out on the wire, so this is a source-level rename with no
behavior change. The additive part (multi-speaker dialogue, custom
pronunciations, and a handful of new Loop / Toolkit helpers) needs
no migration — see New capabilities
at the end. This page is the consolidated “old → new” view; the
changelog
covers the why.
Almost every breaking rewrite is find-and-replace. The one spot that
needs thought is the Loop helper trim (nextAfter / stopAfter and
friends removed) — covered in its own section below.
At a glance
| Before | After | Kind |
|---|---|---|
import … from "@effect-uai/core/Outcome" | import … from "@effect-uai/core/ToolResult" | rename |
import … from "@effect-uai/core/Resolvers" | import … from "@effect-uai/core/Approval" | rename |
Item | HistoryItem | rename |
FunctionCall / FunctionCallOutput | ToolCall / ToolCallOutput | rename |
Items.functionCallOutput(…) | Items.toolCallOutput(…) | rename |
Items.isFunctionCall / Items.isFunctionCallOutput | Items.isToolCall / Items.isToolCallOutput | rename |
Turn.functionCalls(turn) | Turn.getToolCalls(turn) | rename |
Turn.appendTurn(…) | Turn.appendToHistory(…) | rename |
Turn.toStructured(…) | Turn.decodeStructured(…) | rename |
ToolResult.Value / isValue | ToolResult.Ok / isOk | rename |
rejected(call, kind, reason) | failed(call, kind, reason) | rename |
toFunctionCallOutput(result) | toToolCallOutput(result) | rename |
Toolkit.executeAll(tools, calls) | Toolkit.run(tools, calls) | rename |
Toolkit.continueWith(build) | Toolkit.continueWithResults(build) | rename |
Toolkit.make([…]) + Toolkit.toDescriptors(kit) | Tool.toDescriptors([…]) | removed |
Tool.AnyKindTool | Tool.AnyTool | rename |
ToolEvent.Intermediate / isIntermediate | ToolEvent.Progress / isProgress | rename |
ToolCallDecision | ApprovalDecision | rename |
fromApprovalMap(…) / fromVerdictQueue(…) | fromMap(…) / fromQueue(…) | rename |
fromVerdictQueue(…).announce | fromQueue(…).approvalRequests | rename |
Loop.loopFrom(…) | Loop.loopOver(…) | rename |
Loop.Event<A, S> | Loop.Step<A, S> | rename |
return stop | return stop() | reshape |
Loop.stopWith(state) | Loop.stop(state) | rename |
Loop.nextAfter(stream, s) | stream.pipe(Stream.map(value), Stream.concat(next(s))) | removed |
Loop.stopAfter / stopWithAfter / nextAfterFold | compose with Stream.concat(stop()) / Stream.concat(stop(s)) | removed |
”Function call” → “tool call” terminology
The biggest sweep, but the most mechanical: every public name that said
“function call” now says “tool call”. The wire literals are
untouched — items still serialize with type: "function_call" /
type: "function_call_output", so matching on the wire string keeps
working and no provider payloads change.
Item → HistoryItem
// Beforeimport type { Item } from "@effect-uai/core/Items"const history: ReadonlyArray<Item> = [...]
// Afterimport type { HistoryItem } from "@effect-uai/core/Items"const history: ReadonlyArray<HistoryItem> = [...]FunctionCall / FunctionCallOutput → ToolCall / ToolCallOutput
// Beforetype Item = Message | FunctionCall | FunctionCallOutput | Reasoning
import { functionCallOutput, isFunctionCall, isFunctionCallOutput } from "@effect-uai/core/Items"const out = functionCallOutput({ call_id, output })items.filter(isFunctionCall)
// Aftertype HistoryItem = Message | ToolCall | ToolCallOutput | Reasoning
import { toolCallOutput, isToolCall, isToolCallOutput } from "@effect-uai/core/Items"const out = toolCallOutput({ call_id, output })items.filter(isToolCall)toolCallOutput(...) still produces an item whose wire type is
"function_call_output". If you pattern-match on item.type === "function_call", leave that string as-is.
Turn helpers
// Beforeconst calls = Turn.functionCalls(turn) // FunctionCall[]const next = Turn.appendTurn(state, turn, outputs)const parsed = yield * Turn.toStructured(turn, format)
// Afterconst calls = Turn.getToolCalls(turn) // ToolCall[]const next = Turn.appendToHistory(state, turn, outputs)const parsed = yield * Turn.decodeStructured(turn, format)Module: Outcome → ToolResult
The module that synthesizes structured tool results is now named for what it produces. The import path and a couple of member names change.
// Beforeimport { toFunctionCallOutput, rejected } from "@effect-uai/core/Outcome"import * as ToolResult from "@effect-uai/core/Outcome"
ToolResult.Value({ call_id, tool, value }) // success variantToolResult.isValue(result)const denied = rejected(call, "denied", "policy blocked it")outputs.push(toFunctionCallOutput(result))
// Afterimport { toToolCallOutput, failed } from "@effect-uai/core/ToolResult"import * as ToolResult from "@effect-uai/core/ToolResult"
ToolResult.Ok({ call_id, tool, value }) // success variantToolResult.isOk(result)const denied = failed(call, "denied", "policy blocked it")outputs.push(toToolCallOutput(result))The Failure variant, the denied / cancelled / executionError
synthesizers, and the wire shape are unchanged — only the success
variant (Value → Ok, isValue → isOk), the custom-kind
synthesizer (rejected → failed), and the wire converter
(toFunctionCallOutput → toToolCallOutput) move.
Module: Resolvers → Approval
The approval-gating module is renamed to match the domain it models.
// Beforeimport { fromApprovalMap, fromVerdictQueue, ToolCallDecision } from "@effect-uai/core/Resolvers"
const plan = fromApprovalMap(isSensitive, approvals)(calls)const { approved, decisions, announce } = yield * fromVerdictQueue(isSensitive, verdicts)(calls)const d: ToolCallDecision = ToolCallDecision.Approved({ call })
// Afterimport { fromMap, fromQueue, ApprovalDecision } from "@effect-uai/core/Approval"
const plan = fromMap(isSensitive, approvals)(calls)const { approved, decisions, approvalRequests } = yield * fromQueue(isSensitive, verdicts)(calls)const d: ApprovalDecision = ApprovalDecision.Approved({ call })Note the renamed field on the queue helper’s result: announce →
approvalRequests. The approve / reject sugar and the approved /
decisions fields keep their names.
Toolkit: executeAll → run, continueWith → continueWithResults
// Beforeimport * as Toolkit from "@effect-uai/core/Toolkit"
return Toolkit.executeAll(tools, calls).pipe( Toolkit.continueWith((results) => Turn.appendTurn(state, turn, results.map(toFunctionCallOutput)), ),)
// Afterimport * as Toolkit from "@effect-uai/core/Toolkit"
return Toolkit.run(tools, calls).pipe( Toolkit.continueWithResults((results) => Turn.appendToHistory(state, turn, results.map(toToolCallOutput)), ),)Removed: Toolkit.make / Toolkit.toDescriptors
The homogeneous-toolkit wrapper is gone. Build a flat array of tools
(plain, streaming, or mixed) and render it with Tool.toDescriptors.
// Beforeimport * as Toolkit from "@effect-uai/core/Toolkit"const toolkit = Toolkit.make([getTime, lookupWeather])const descriptors = Toolkit.toDescriptors(toolkit)
// Afterimport * as Tool from "@effect-uai/core/Tool"const descriptors = Tool.toDescriptors([getTime, lookupWeather])Tool: AnyKindTool → AnyTool
// Beforeconst tools: ReadonlyArray<Tool.AnyKindTool> = [getTime, askSubagent]
// Afterconst tools: ReadonlyArray<Tool.AnyTool> = [getTime, askSubagent]ToolEvent: Intermediate → Progress
The streaming-tool progress variant is renamed; Output and
ApprovalRequested are unchanged.
// BeforeMatch.value(event).pipe( Match.discriminators("_tag")({ Intermediate: (e) => renderProgress(e), Output: (e) => commit(e.result), ApprovalRequested: (e) => prompt(e), }),)ToolEvent.isIntermediate(event)
// AfterMatch.value(event).pipe( Match.discriminators("_tag")({ Progress: (e) => renderProgress(e), Output: (e) => commit(e.result), ApprovalRequested: (e) => prompt(e), }),)ToolEvent.isProgress(event)Loop: loopFrom → loopOver, Event → Step, and a trimmed helper set
Two renames and one real cleanup.
Renames. The input-driven loop is now loopOver, and the body’s
emit type is Step (was Event):
// Beforeimport { loopFrom } from "@effect-uai/core/Loop"const body = (state: S, item: I): Stream<Loop.Event<A, S>> => ...input.pipe(loopFrom(initial, body))
// Afterimport { loopOver } from "@effect-uai/core/Loop"const body = (state: S, item: I): Stream<Loop.Step<A, S>> => ...input.pipe(loopOver(initial, body))next and stop are single-element streams; the *After helpers
are gone. Previously nextAfter / stopAfter / stopWithAfter
bundled “emit these values, then continue/stop” into one call. Now
next(state), stop(), and stop(state) each emit a single terminal
step, and you concatenate your values in front of them with ordinary
Stream combinators. 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))) |
// Beforeimport { value, next, stop, stopWith, nextAfter } from "@effect-uai/core/Loop"return nextAfter(deltaStream, nextState) // emit deltas, then continue
// Afterimport { value, next, stop } from "@effect-uai/core/Loop"return deltaStream.pipe(Stream.map(value), Stream.concat(next(nextState)))// …or just `next(nextState)` when there were no values to emit firststopEvent and nextAfterFold are removed with no direct
replacement — build the step stream you want from value / next /
stop and standard Stream combinators.
The canonical loop body, before and after
The most common shape — drain a turn, run the requested tools, fold the outputs back into history — changes name-for-name but keeps its structure:
// BeforeonTurnComplete<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)), ), )})
// AfteronTurnComplete<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)), ), )})New capabilities (additive — no migration needed)
0.6 also ships features that don’t touch existing call sites. Adopt them when you need them; nothing breaks if you ignore them.
Multi-speaker dialogue + custom pronunciations on SpeechSynthesizer
synthesizeDialogue/streamSynthesizeDialogueonSpeechSynthesizerService, taking aCommonSynthesizeDialogueRequest({ model, turns, outputFormat?, languageCode?, pronunciations? }) where eachDialogueTurnis{ voiceId, text, styleDescription?, speed? }.- A new
MultiSpeakerTtscapability marker — shipped only by provider layers with native dialogue support. The top-levelsynthesizeDialogue/streamSynthesizeDialoguehelpers require it inR, so a dialogue-less provider fails at compile time (mirrors the existingTtsIncrementalTextmarker). - Optional
pronunciations?: ReadonlyArray<CustomPronunciation>onCommonSynthesizeRequest, withCustomPronunciation({ phrase, pronunciation, encoding }) andPhoneticEncoding("ipa" | "x-sampa" | "cmu-arpabet"). Adapters that can’t honor an entry drop it silently and still render with the default pronunciation. MockSpeechSynthesizergainsdialogueBlobs/streamSynthesizeDialogueChunksscript fields and alayerWithoutMultiSpeakervariant for testing the marker.
New Toolkit history helpers
Toolkit.appendToolResults(state, turn) is shorthand for the common
continueWithResults body — it folds the collected results into history
for you, so you rarely hand-write the .map(toToolCallOutput):
return Toolkit.run(tools, calls).pipe( Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),)Toolkit.collectResults is the lower-level counterpart: it drains a
Stream<ToolEvent> to its ToolResults without advancing the loop.
Sandboxes (@effect-uai/core/Sandbox + two adapters)
A new Sandbox capability lets agents run untrusted code, commands,
or LLM-generated scripts inside an isolated microVM. Two provider
packages implement the same SandboxService (apart from the
provider-specific create request shape):
@effect-uai/microsandbox— local Firecracker microVMs via microsandbox. Best for local dev, integration tests, and self-hosted setups.@effect-uai/deno— hosted Firecracker microVMs on Deno Deploy. No infra to run; good for production agents that need on-demand sandboxing.
Both packages cover the full surface — create / exec / execStream
/ volumes / snapshots / network policies / bound secrets / OCI images.
Swap providers with a Layer change:
import * as Sandbox from "@effect-uai/core/Sandbox"import * as Image from "@effect-uai/core/SandboxImage"import * as Network from "@effect-uai/core/SandboxNetwork"
const program = Effect.gen(function* () { const sb = yield* Sandbox.create({ image: Image.registry("python:3.12-slim"), network: Network.blocked, }) const r = yield* sb.exec({ cmd: ["python3", "-c", "print(2 + 2)"] }) console.log(r.stdout) // "4"}).pipe(Effect.scoped) // destroys the sandbox when scope closesSee the effect-uai-sandbox-basics skill, the
recipes-extras/sandbox-code-interpreter recipe, and the
Sandboxes docs section for the full surface.
New recipes
Two additional recipes ship in 0.6:
sleeper-agent: long-lived background agent that wakes on scheduled triggers, performs a task, and goes back to sleep.sandbox-code-interpreter(inrecipes-extras/): the “run, fix, repeat” pattern. The model writes Python, the sandbox runs it, stderr feeds back into the next turn until the code works. Lives inrecipes-extras/because it needs a sandbox provider running.
Recommended order
- Swap the two module import paths:
@effect-uai/core/Outcome→@effect-uai/core/ToolResultand@effect-uai/core/Resolvers→@effect-uai/core/Approval. - Find-and-replace the flat renames:
functionCalls→getToolCalls,appendTurn→appendToHistory,toStructured→decodeStructured,executeAll→run,continueWith→continueWithResults,AnyKindTool→AnyTool,Intermediate→Progress,loopFrom→loopOver,Loop.Event→Loop.Step,toFunctionCallOutput→toToolCallOutput. - Rename the type/constructor families:
Item→HistoryItem,FunctionCall(Output)→ToolCall(Output),ToolResult.Value→ToolResult.Ok,rejected→failed,fromApprovalMap→fromMap,fromVerdictQueue→fromQueue,ToolCallDecision→ApprovalDecision, theannouncefield →approvalRequests. - Replace
Toolkit.make(...)+Toolkit.toDescriptors(...)withTool.toDescriptors([...]). - Fix the
Loophelper trim:stop→stop(),stopWith(s)→stop(s), and rewrite eachnextAfter/stopAfter/stopWithAfterto theStream.concatform above. - Leave wire literals (
"function_call","function_call_output") untouched — they are unchanged. - Run typecheck, then tests.
Using Claude to migrate
The effect-uai-migrate skill encodes these rules. From Claude Code:
/skill effect-uai-migrateOr just point Claude at this page — the rewrites are mechanical enough that any tool-using LLM can apply them.