Skip to content

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.

// before
Toolkit.run(toolkit, calls).pipe(
Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
)
// after
Toolkit.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 with build(outcomes). build returns 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).

BeforeAfter
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.ApprovalRequestedremoved: announce approvals with your own event
isOutput, isProgress, isApprovalRequestedToolkit.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.

// before
run: ({ url }, emit) =>
download(url).pipe(
Stream.runFoldEffect(() => 0, (n, chunk) => emit({ pct: chunk.pct }).pipe(Effect.as(n + 1))),
Effect.map((chunks) => ({ chunks })),
)
// after
outputSchema: 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:

// before
Tool.make({ name, description, inputSchema, run: ({ city }) => lookup(city) })
// after
Tool.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.

// before
run: ({ city }) =>
Effect.gen(function* () {
const weather = yield* lookup(city)
if (weather === undefined) return yield* Effect.fail(`Unknown city: ${city}`)
return weather
})
// after
class 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
}),
  • ToolFailed and Tool.fail(message) remain, as a ready-made error to list in failureSchema. The kind option is removed.
  • Toolkit.describeFailures(toolkit, describe) turns every undeclared error into a ToolFailed the model reads; declared errors pass through. Return undefined from describe to 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_errorInputInvalid
output_validation_errorOutputInvalid
unknown_tool, non_local_toolUnknownTool
cancelled (from HistoryCheck)Cancelled

A Stream tool that emits nothing also ends with OutputInvalid.

Tool kinds

  • Tool.signal and Tool.interaction are removed: a tool without run is Tool.declare (outputSchema optional, typing an answer given elsewhere). The loop handles its calls; Toolkit.split decodes them (below). If one reaches Toolkit.run, it is closed with UnknownTool. Tool.make requires run.

    // before
    const escalate = Tool.signal({ name: "escalate", description, inputSchema })
    // after
    const escalate = Tool.declare({ name: "escalate", description, inputSchema })
  • New: Toolkit.split(toolkit, turn) replaces finding and decoding declared calls by hand. It returns run (for Toolkit.run), declared (decoded, typed per tool, for an exhaustive Match) and invalid (closed with InputInvalid). Toolkit.complete(toolkit, "ask_user", call, value) answers a call with a value typed by outputSchema, the success side of Toolkit.close.

    // before
    const call = Turn.getToolCalls(turn).find((c) => c.name === "escalate")
    const args = yield * Tool.decodeArgs(escalate, call)
    // after
    const { run, declared, invalid } = yield * Toolkit.split(toolkit, turn)
  • Tool.execute is removed. Use Toolkit.run(Toolkit.make(tool), [call]).

  • Tool.withRun keeps the descriptor and the schemas; the new run returns the same output type. On a declared tool it makes a Tool.

Middleware

Toolkit.wrap’s middleware receives run as a stream (an Effect tool is a one-element stream) and returns one:

// before
const audited: Toolkit.Middleware<Audit> = (run, name) => (input, emit) =>
Effect.zipRight(record(name), run(input, emit))
// after
const 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 | InputFile

input_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 call

ToolDecision.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.

// before
const plan = Approval.fromMap(isSensitive, approvals)(calls)
Stream.merge(
Toolkit.run(toolkit, plan.approved),
Stream.fromIterable(plan.rejected.map((result) => ToolEvent.Output({ result }))),
)
// after
const { 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 recipe

A 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:

// before
const closing = HistoryCheck.cancelAllPending(history).map(toToolCallOutput)
// after
const 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_error for failures.
  • Gemini: the result goes in functionResponse.response as { output } (or { error } for a failure), an object, never a string. Before, a JSON object was sent at the top level of response. Media goes in functionResponse.parts.
  • Chat Completions, Mistral, OpenAI Realtime: tool messages carry text only, so a tool output with media fails the request with AiError.Unsupported instead of being dropped.
  • MCP: a tool’s images, audio and resources arrive as media instead of an “omitted” placeholder; structuredContent becomes the JSON value; an isError result is the declared McpToolFailed.

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.

BeforeAfter
tool-progress { callId, data }tool-partial { callId, name, value }
tool-result { output, isError }tool-result { result: Success { value } | Failure { error } }
tool block output, error, progresspartial?, outcome?
Input.Decision { callId, approved, reason? }{ callId, decision }
Projection.toRunEvents()Projection.toRunEvents({ contract }) (required)
// before
if (block.status === "failed") return failure(block.error)
return <Weather value={block.output} />
// after
if (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’s failureSchema (app closings such as ToolDecision.Denied included) and core’s errors (ToolOutput.CoreError) decode back to their classes. A Content.Value tool (or a name the contract doesn’t list) travels as typed parts and arrives as a Content.Value, an image as a Content.Image; JSON that merely looks like media stays JSON. A core Tool is now typed by its own output and failure types. A spec’s outputSchema / failureSchema must encode to JSON (as for Tool.make): the UI encodes with the same schema as history, so both see the same value.
  • Server: contract.toolEvent(event) encodes what Toolkit.run emits; contract.toolOutput(item, name) a history item; contract.success(call, value) and contract.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.