Migrating to 0.19
0.19 redesigns tool output. A tool returns a typed value (text, JSON, an image, a file, or a mix); history stores it as typed parts instead of a string; each provider serializes it once, natively. Errors a tool declares reach the model as typed values, and the UI wire carries the same types to the browser.
Most loops change one line. Tools that used emit, string failures or
Tool.signal, and UIs that read output / isError, change more.
Suggested order: the loop helpers, then tools (errors, emit, kinds), then
approvals, then the UI. Run typecheck after each step.
The loop: Toolkit.next
continueWithResults(appendToolResults(...)) is now one call. It takes the
toolkit, because encoding a result for history uses the tool’s schemas.
// beforeToolkit.run(toolkit, calls).pipe( Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),)
// afterToolkit.run(toolkit, calls).pipe(Toolkit.next(toolkit, state, turn))For a custom state, the two halves are still there:
Toolkit.continueWith(build): forwards every event, then continues withbuild(outcomes).buildreturns an Effect now.Toolkit.appendResults(toolkit, state, turn): encodes each outcome and appends the turn plus the outputs to history.
Toolkit.run(toolkit, calls).pipe( Toolkit.continueWith((outcomes) => Toolkit.appendResults(toolkit, { ...state, trail: trailOf(outcomes) }, turn)(outcomes), ),)Toolkit.collectResults is removed; the explicit form is
Stream.filter(Toolkit.isOutcome) plus Stream.runCollect.
Tool events
Toolkit.run emits ToolEvent.Partial | ToolEvent.Output, typed per tool:
tool is the discriminant, so e.tool === "search" narrows value and
result to that tool’s types. Values are decoded (a Date stays a Date).
| Before | After |
|---|---|
ToolEvent.Output { result: ToolResult } | ToolEvent.Output { call_id, tool, result: Result<Output, Error> } |
ToolEvent.Progress { call_id, tool, data } | ToolEvent.Partial { call_id, tool, value: Output } |
ToolEvent.ApprovalRequested | removed: announce approvals with your own event |
isOutput, isProgress, isApprovalRequested | Toolkit.isOutcome (also works on a loop’s mixed stream) |
ToolResult.Ok / ToolResult.Failure { kind } | Effect’s Result: Success / Failure with a typed error |
toToolCallOutput(result) | Toolkit.output(toolkit, event) (an Effect) |
The ToolResult module is gone. To get history items without the loop
helpers:
const outputs = yield * Toolkit.run(toolkit, calls).pipe( Stream.filter(Toolkit.isOutcome), Stream.mapEffect(Toolkit.output(toolkit)), Stream.runCollect, )Toolkit.decode(toolkit, name, item) reads a history item back as a typed
Result.
On an event not narrowed by tool, result is a union of per-tool Results,
which Result.match does not accept. Check result._tag there, or narrow by
tool first.
Tools
run returns an Effect or a Stream; no emit
run takes the input only. A tool that reported progress returns a Stream:
every element reaches the consumer as a Partial as soon as it is produced,
and the last one is then also the result the model sees (an Effect tool
emits only its result). A partial has the tool’s own output type, so make
progress part of that type.
// beforerun: ({ url }, emit) => download(url).pipe( Stream.runFoldEffect(() => 0, (n, chunk) => emit({ pct: chunk.pct }).pipe(Effect.as(n + 1))), Effect.map((chunks) => ({ chunks })), )
// afteroutputSchema: Schema.Struct({ pct: Schema.Number, done: Schema.Boolean }),run: ({ url }) => download(url).pipe(Stream.map((chunk) => ({ pct: chunk.pct, done: chunk.last }))),emitBufferSize and the Emit type are removed. The tool type parameters are
now Tool<Name, Input, Output, Failure, E, R> (was
Tool<Name, Input, Event, Output, E, R>).
What run may return
A tool with run now requires outputSchema: run returns its type, and
its encoded side must be JSON (Schema.Date is a type error; use
Schema.DateFromString). Add one to every tool:
// beforeTool.make({ name, description, inputSchema, run: ({ city }) => lookup(city) })
// afterTool.make({ name, description, inputSchema, outputSchema: Weather, run: ({ city }) => lookup(city),})Content.Value is the explicit “anything”: text, JSON, one medium, or a
top-level array of JSON and media (what a tool without outputSchema returned
before). Images and files are classes from the new Content module:
import * as Content from "@effect-uai/core/Content"import { imageBytes } from "@effect-uai/core/Image"
outputSchema: Content.Value,run: ({ url }) => Effect.map(screenshot(url), (png) => [ { title: "Home" }, Content.image(imageBytes(png, "image/png")), ]),Each provider sends media natively (see Providers below).
Errors: declare what the model may see
Before, a string failure or a ToolFailed became a model-visible result.
Now a tool lists the errors the model may see in failureSchema; any other
error fails Toolkit.run’s stream, typed.
// beforerun: ({ city }) => Effect.gen(function* () { const weather = yield* lookup(city) if (weather === undefined) return yield* Effect.fail(`Unknown city: ${city}`) return weather })
// afterclass CityNotFound extends Schema.TaggedError<CityNotFound>()("CityNotFound", { city: Schema.String,}) {}
failureSchema: CityNotFound,run: ({ city }) => Effect.gen(function* () { const weather = yield* lookup(city) if (weather === undefined) return yield* new CityNotFound({ city }) return weather }),ToolFailedandTool.fail(message)remain, as a ready-made error to list infailureSchema. Thekindoption is removed.Toolkit.describeFailures(toolkit, describe)turns every undeclared error into aToolFailedthe model reads; declared errors pass through. Returnundefinedfromdescribeto keep an error fatal:
Toolkit.describeFailures(toolkit, (e: BrowserError) => e._tag === "BrowserSessionExpired" ? undefined : BrowserError.describe(e),)Calls that never ran fail with core’s tagged errors instead of kind strings:
Before (kind) | After (ToolOutput) |
|---|---|
input_validation_error | InputInvalid |
output_validation_error | OutputInvalid |
unknown_tool, non_local_tool | UnknownTool |
cancelled (from HistoryCheck) | Cancelled |
A Stream tool that emits nothing also ends with OutputInvalid.
Tool kinds
-
Tool.signalandTool.interactionare removed: a tool withoutrunisTool.declare(outputSchemaoptional, typing an answer given elsewhere). The loop handles its calls;Toolkit.splitdecodes them (below). If one reachesToolkit.run, it is closed withUnknownTool.Tool.makerequiresrun.// beforeconst escalate = Tool.signal({ name: "escalate", description, inputSchema })// afterconst escalate = Tool.declare({ name: "escalate", description, inputSchema }) -
New:
Toolkit.split(toolkit, turn)replaces finding and decoding declared calls by hand. It returnsrun(forToolkit.run),declared(decoded, typed per tool, for an exhaustiveMatch) andinvalid(closed withInputInvalid).Toolkit.complete(toolkit, "ask_user", call, value)answers a call with a value typed byoutputSchema, the success side ofToolkit.close.// beforeconst call = Turn.getToolCalls(turn).find((c) => c.name === "escalate")const args = yield * Tool.decodeArgs(escalate, call)// afterconst { run, declared, invalid } = yield * Toolkit.split(toolkit, turn) -
Tool.executeis removed. UseToolkit.run(Toolkit.make(tool), [call]). -
Tool.withRunkeeps the descriptor and the schemas; the newrunreturns the same output type. On a declared tool it makes aTool.
Middleware
Toolkit.wrap’s middleware receives run as a stream (an Effect tool is a
one-element stream) and returns one:
// beforeconst audited: Toolkit.Middleware<Audit> = (run, name) => (input, emit) => Effect.zipRight(record(name), run(input, emit))
// afterconst audited: Toolkit.Middleware<Audit> = (run, name) => (input) => Stream.unwrap(Effect.as(record(name), run(input)))History: typed parts
ToolCallOutput.output is now an array of parts instead of a string, plus an
optional isError:
type ToolOutputContent = InputText | InputJson | InputImage | InputFileinput_json holds the value as a value; the provider codec serializes it.
Items.toolCallOutput(call_id, text) still builds a one-text-part output. New
constructors: Items.text, Items.json, Items.image, Items.file.
Code that read output as a string reads the parts, or ToolOutput.value(output)
for the value they encode.
Approvals and other decisions
Approval is now ToolDecision (@effect-uai/core/ToolDecision): it defines
what an answer to a waiting tool call can be (approve, deny, edit, anything)
and groups waiting calls by answer. Approval.decisions is ToolDecision.make:
const Decisions = ToolDecision.make({ Approve: { always: Schema.optionalKey(Schema.Boolean) }, // An answer that ends the call: it carries the error it builds. Deny: ToolDecision.closesWith( { reason: Schema.optionalKey(Schema.String) }, ({ reason }) => new ToolDecision.Denied({ message: reason ?? "Declined." }), ),})const { answered, Approve, Deny, unanswered, invalid } = Decisions.resolve(waiting, answers)// answered: every answered call, tagged like its decision (for an exhaustive Match)// Deny[i].error is the Denied; invalid holds answers this set doesn't take,// including two different answers to the same callToolDecision.Default is approve, or deny with an optional reason (its Deny
ends the call with ToolDecision.Denied). When tools take different answers,
ToolDecision.byTool({ issue_refund: RefundDecisions }, { fallback: ToolDecision.Default })
gives typed answer(interrupt, decision), one resolve and the wire schema
for Input.make(data, Decisions.schema); two sets may share a tag only with
the same fields (a compile error otherwise). Decisions are app code; tools and
toolkits don’t know them. Removed:
ApprovalDecision, approve, reject, splitApprovalDecisions,
approvalRequested, fromMap and ApprovalMapEntry.
// beforeconst plan = Approval.fromMap(isSensitive, approvals)(calls)Stream.merge( Toolkit.run(toolkit, plan.approved), Stream.fromIterable(plan.rejected.map((result) => ToolEvent.Output({ result }))),)
// afterconst { Approve, Deny, unanswered } = ToolDecision.Default.resolve( calls.filter(isSensitive), answers,)const run = [...calls.filter((c) => !isSensitive(c)), ...Approve.map((a) => a.call)]// close Deny and unanswered with your own errors, see the recipeA call the app closes itself still needs an output. The tools that need
approval declare the closing errors in their failureSchema (or get them with
Tool.withFailures(tool, ToolDecision.Denied), or Toolkit.withFailures for a
whole toolkit), and Toolkit.close builds the outcome, which
Toolkit.output / next encode like any other:
const sendEmail = Tool.make({ ...spec, failureSchema: ToolDecision.Denied, run })
const closed = yield * Toolkit.close(toolkit, call, new ToolDecision.Denied({ message: "Not now" }))// Stream.merge(Toolkit.run(toolkit, approved), Stream.succeed(closed)).pipe(Toolkit.next(toolkit, state, turn))close fails with UndeclaredFailure when the call’s tool doesn’t declare the
error (core’s errors close any call). Pass the tool’s name,
Toolkit.close(toolkit, "send_email", call, error), to check the error against
that tool at compile time.
For a value the user supplied, Toolkit.complete(toolkit, "tool", call, value),
typed by the tool’s outputSchema.
The tool call approval recipe shows both
transports, including a fromQueue built on these pieces.
Approval.fromQueue (and Verdict, FromQueueOptions) also left core in this
release; copy the recipe’s version if you used it.
HistoryCheck.cancelAllPending(history, reason?) now returns an Effect of
ready-to-append ToolCallOutputs (a Cancelled error each), so the
toToolCallOutput step goes away:
// beforeconst closing = HistoryCheck.cancelAllPending(history).map(toToolCallOutput)
// afterconst closing = yield * HistoryCheck.cancelAllPending(history)Providers
No code change, but what reaches the model changes:
- Responses, Anthropic: media in a tool output goes as native image and
file/document parts. Anthropic sends
is_errorfor failures. - Gemini: the result goes in
functionResponse.responseas{ output }(or{ error }for a failure), an object, never a string. Before, a JSON object was sent at the top level ofresponse. Media goes infunctionResponse.parts. - Chat Completions, Mistral, OpenAI Realtime: tool messages carry text
only, so a tool output with media fails the request with
AiError.Unsupportedinstead of being dropped. - MCP: a tool’s images, audio and resources arrive as media instead of an
“omitted” placeholder;
structuredContentbecomes the JSON value; anisErrorresult is the declaredMcpToolFailed.
Loop typing
Loop.loop and onTurnComplete infer the error and requirements of a body’s
outer Effect separately from those of the stream it returns. Bodies that failed
to typecheck when the Effect had its own error now compile; nothing to change.
@effect-uai/ui
The wire protocol is version 2.
| Before | After |
|---|---|
tool-progress { callId, data } | tool-partial { callId, name, value } |
tool-result { output, isError } | tool-result { result: Success { value } | Failure { error } } |
tool block output, error, progress | partial?, outcome? |
Input.Decision { callId, approved, reason? } | { callId, decision } |
Projection.toRunEvents() | Projection.toRunEvents({ contract }) (required) |
// beforeif (block.status === "failed") return failure(block.error)return <Weather value={block.output} />
// afterif (block.outcome?._tag === "Failure") return failure(block.outcome.error)if (block.outcome?._tag === "Success") return <Weather value={block.outcome.value} />- Contract:
UiContract.make({ tools }). Each tool’sfailureSchema(app closings such asToolDecision.Deniedincluded) and core’s errors (ToolOutput.CoreError) decode back to their classes. AContent.Valuetool (or a name the contract doesn’t list) travels as typed parts and arrives as aContent.Value, an image as aContent.Image; JSON that merely looks like media stays JSON. A coreToolis now typed by its own output and failure types. A spec’soutputSchema/failureSchemamust encode to JSON (as forTool.make): the UI encodes with the same schema as history, so both see the same value. - Server:
contract.toolEvent(event)encodes whatToolkit.runemits;contract.toolOutput(item, name)a history item;contract.success(call, value)andcontract.failure(call, error)encode calls the app closes.Projection.toRunEvents({ contract })uses them and requires the contract (UiContract.make({})for a chat without tools); a value that does not fit its schema fails the stream. - Answers:
Input.resolve([{ callId, decision: ToolDecision.Default.Approve() }]).Input.make(data, Decisions.schema)takes the app’s own decisions.
@effect-uai/ai-sdk
A tool output goes to useChat as a JSON value (text as a string, media as
{ type, url } with a data URL), and a failed call as a tool-output-error
part. A tool output arriving from the client stays a JSON value in history.