This library provides composable filter and transformation utilities for UI message streams created by streamText() in the AI SDK.
The AI SDK UI message stream created by toUIMessageStream() streams all parts (text, tools, reasoning, etc.) to the client by default. However, you may want to:
- Filter: Tool calls like database searches often contain large amounts of data or sensitive information that should not be streamed to the client
- Transform: Modify text or tool outputs while they are streamed to the client
- Observe: Log stream lifecycle events, update states, or run side-effects without modifying the stream
This library provides type-safe, composable utilities for all these use cases.
Note
Version compatibility:
- Use
ai-stream-utils@2.xfor AI SDK v6 - Use
ai-stream-utils@3.xfor AI SDK v7
npm install ai-stream-utils@2 # AI SDK v6
npm install ai-stream-utils@3 # AI SDK v7The pipe function provides a composable pipeline API for filtering, transforming, and observing UI message streams. Multiple operators can be chained together, and type guards automatically narrow chunk and part types, thus enabling type-safe stream transformations with autocomplete.
Filter chunks by returning true to keep or false to exclude. The predicate may be async.
const stream = pipe(result.toUIMessageStream())
.filter(({ chunk, part }) => {
// chunk.type: "text-delta" | "text-start" | "tool-input-available" | ...
// part.type: "text" | "reasoning" | "tool-weather" | ...
if (chunk.type === "data-weather") {
return false; // exclude chunk
}
return true; // keep chunk
})
.toStream();Generic type guards provide a simpler API for common filtering patterns:
includeChunks("text-delta")orincludeChunks(["text-delta", "text-end"]): Include only specific chunk typesexcludeChunks("text-delta")orexcludeChunks(["text-delta", "text-end"]): Exclude only specific chunk typesincludeParts("text")orincludeParts(["text", "reasoning"]): Include only specific part typesexcludeParts("reasoning")orexcludeParts(["reasoning", "tool-database"]): Exclude only specific part types
Filtering tools is the most common use case and the tool-filter type guards provide a convenient API for filtering tool chunks by tool name:
excludeTools()orexcludeTools("weather")orexcludeTools(["weather", "database"]): Exclude all tools or specific tools by nameincludeTools()orincludeTools("weather")orincludeTools(["weather", "database"]): Include all tools or specific tools by name
Note
The tool-filter type guards only affect tool chunks. Non-tool chunks will pass through.
Exclude tool calls from the client.
// Exclude by part type (requires "tool-" prefix)
const stream = pipe(result.toUIMessageStream())
.filter(excludeParts(["tool-weather", "tool-database"]))
.toStream();
// Exclude by tool name (without "tool-" prefix)
const stream = pipe(result.toUIMessageStream())
.filter(excludeTools(["weather", "database"]))
.toStream();
// Exclude all tools
const stream = pipe(result.toUIMessageStream()).filter(excludeTools()).toStream();
// Include only specific tools (without "tool-" prefix)
const stream = pipe(result.toUIMessageStream())
.filter(includeTools(["weather"]))
.toStream();Note
excludeTools() and includeTools() filters tool chunks on the server before streaming to the client. This affects all tool types including:
- Server-side tools with
executefunctions - Client-side tools without
executefunctions - Tools that require human approval via
needsApproval
Excluded tools will not appear in the client's message parts, so users won't see tool call UI or be able to approve/reject filtered tools.
Transform chunks by returning a chunk, an array of chunks, or null to exclude. The callback may be async, which lets a chunk be rewritten from an awaited result.
const stream = pipe(result.toUIMessageStream())
.map(({ chunk, part }) => {
// chunk.type: "text-delta" | "text-start" | "tool-input-available" | ...
// part.type: "text" | "reasoning" | "tool-weather" | ...
if (chunk.type === "text-start") {
return chunk; // pass through unchanged
}
if (chunk.type === "text-delta") {
return { ...chunk, delta: "modified" }; // transform chunk
}
if (chunk.type === "data-weather") {
return [chunk1, chunk2]; // emit multiple chunks
}
return null; // exclude chunk (same as filter)
})
.toStream();Convert text to uppercase.
const stream = pipe(result.toUIMessageStream())
.map(({ chunk }) => {
if (chunk.type === "text-delta") {
return { ...chunk, delta: chunk.delta.toUpperCase() };
}
return chunk;
})
.toStream();transformProviderMetadata() changes providerMetadata on the chunks that carry it (text-*, reasoning-*, reasoning-file, tool-input-*, tool-output-available, tool-output-error, tool-approval-response, source-*, file, custom). Every other chunk passes through untouched, so you don't have to narrow by chunk type yourself.
The callback receives the current metadata (may be undefined) and returns one of three things:
- an object to set/replace the metadata (merge by spreading
metadata) undefinedto leave the chunk unchangednullto remove theproviderMetadatafield entirely (the chunk still passes through)
const stream = pipe(result.toUIMessageStream())
.map(
transformProviderMetadata(({ chunk, part, metadata }) => {
if (chunk.type === "tool-input-available")
return { ...metadata, app: { toolCallId: chunk.toolCallId } }; // add to tool input chunks
if (part.type === "text") return null; // delete for text parts
return undefined; // leave everything else unchanged
}),
)
.toStream();Observe chunks without modifying the stream. The callback is invoked for matching chunks. Both the predicate and the callback may be async.
const stream = pipe(result.toUIMessageStream())
.on(
(predicate) => {
const { chunk, part } = predicate;
// return true to invoke callback, false to skip
return chunk.type === "text-delta";
},
(callback) => {
const { chunk, part } = callback;
// callback invoked for matching chunks
console.log(chunk, part);
},
)
.toStream();Type guard provides a type-safe way to observe specific chunk types:
chunkType("text-delta")orchunkType(["start", "finish"]): Observe specific chunk typespartType("text")orpartType(["text", "reasoning"]): Observe chunks belonging to specific part typestoolCall()ortoolCall({ tool: "weather" })ortoolCall({ state: "output-available" }): Observe tool state transitions
Note
The partType type guard still operates on chunks. That means partType("text") will match any text chunks such as text-start, text-delta, and text-end.
The toolCall() type guard matches tool chunks representing state transitions (not streaming events):
input-available: Tool input fully parsedapproval-requested: Tool awaiting user approvaloutput-available: Tool execution completedoutput-error: Tool execution failedoutput-denied: User denied approval
Log stream lifecycle events:
const stream = pipe(result.toUIMessageStream())
.on(chunkType("start"), ({ chunk }) => {
console.log("Stream started:", chunk.messageId);
})
.on(chunkType("finish"), ({ chunk }) => {
console.log("Stream finished:", chunk.finishReason);
})
.on(chunkType("tool-input-available"), ({ chunk }) => {
console.log("Tool input:", chunk.input);
})
.on(chunkType("tool-output-available"), ({ chunk }) => {
console.log("Tool output:", chunk.output);
})
.toStream();Observe tool state transitions for a specific tool:
const stream = pipe(result.toUIMessageStream())
.on(toolCall({ tool: "weather", state: "approval-requested" }), ({ chunk }) => {
console.log("Weather tool needs approval");
})
.on(toolCall({ tool: "weather", state: "output-available" }), ({ chunk }) => {
console.log("Weather output:", chunk.output);
})
.on(toolCall({ tool: "weather", state: "input-available" }), ({ chunk }) => {
console.log("Weather input:", chunk.input);
})
.toStream();Observe all tool calls:
const stream = pipe(result.toUIMessageStream())
.on(toolCall({ state: `input-available` }), ({ chunk, part }) => {
console.log(`Tool call ${part.type} (${chunk.toolCallId}) input=`, chunk.input);
})
.on(toolCall({ state: `output-available` }), ({ chunk, part }) => {
console.log(`Tool result ${part.type} (${chunk.toolCallId}) output=`, chunk.output);
})
.on(toolCall({ state: `output-error` }), ({ chunk, part }) => {
console.log(`Tool error ${part.type} (${chunk.toolCallId}) error=`, chunk.errorText);
})
.toStream();Convert the pipeline back to a AsyncIterableStream<InferUIMessageChunk<UI_MESSAGE>> that can be returned to the client or consumed.
const stream = pipe(result.toUIMessageStream())
.filter(({ chunk }) => {})
.map(({ chunk }) => {})
.toStream();
// Iterate with for-await-of
for await (const chunk of stream) {
console.log(chunk);
}
// Consume as ReadableStream
for await (const message of readUIMessageStream({ stream })) {
console.log(message);
}
// Return to client with useChat()
return stream;Multiple operators can be chained together. After filtering with type guards, chunk and part types are narrowed automatically.
const stream = pipe<MyUIMessage>(result.toUIMessageStream())
.filter(includeParts("text"))
.map(({ chunk, part }) => {
// chunk is narrowed to text chunks: "text-start" | "text-delta" | "text-end"
// part is narrowed to "text"
return chunk;
})
.toStream();Control chunks always pass through regardless of filter/transform settings:
start: Stream start markerfinish: Stream finish markerabort: Stream abort markermessage-metadata: Message metadata updateserror: Error messages
Helper functions for consuming streams and converting between streams, arrays, and async iterables.
Consumes a UI message stream by fully reading it and returns the final assembled message. Useful for server-side processing without streaming to the client.
import { consumeUIMessageStream } from "ai-stream-utils";
const result = streamText({
model: openai("gpt-4o"),
prompt: "Tell me a joke",
});
const message = await consumeUIMessageStream(result.toUIMessageStream<MyUIMessage>());
console.log(message.parts); // All parts fully assembledAdds async iterator protocol to a ReadableStream, enabling for await...of loops.
import { createAsyncIterableStream } from "ai-stream-utils";
const asyncStream = createAsyncIterableStream(readableStream);
for await (const chunk of asyncStream) {
console.log(chunk);
}Converts an array to a ReadableStream that emits each element.
import { convertArrayToStream } from "ai-stream-utils";
const stream = convertArrayToStream([1, 2, 3]);Converts an async iterable (e.g., async generator) to a ReadableStream.
import { convertAsyncIterableToStream } from "ai-stream-utils";
async function* generator() {
yield 1;
yield 2;
}
const stream = convertAsyncIterableToStream(generator());Collects all values from an async iterable into an array.
import { convertAsyncIterableToArray } from "ai-stream-utils";
const array = await convertAsyncIterableToArray(asyncIterable);Consumes a ReadableStream and collects all chunks into an array.
import { convertStreamToArray } from "ai-stream-utils";
const array = await convertStreamToArray(readableStream);Converts a UI message stream to an SSE (Server-Sent Events) stream. Useful for sending UI message chunks over HTTP as SSE-formatted text.
import { convertUIMessageToSSEStream } from "ai-stream-utils";
const uiStream = result.toUIMessageStream();
const sseStream = convertUIMessageToSSEStream(uiStream);
// Output format: "data: {...}\n\n" for each chunkConverts an SSE stream back to a UI message stream. Useful for parsing SSE-formatted responses on the client.
import { convertSSEToUIMessageStream } from "ai-stream-utils";
const response = await fetch("/api/chat");
const sseStream = response.body.pipeThrough(new TextDecoderStream());
const uiStream = convertSSEToUIMessageStream(sseStream);The toUIMessageStream() from streamText() returns a generic ReadableStream<UIMessageChunk>, which means the part types cannot be inferred automatically.
To enable autocomplete and type-safety, pass your UIMessage type as a generic parameter:
import type { UIMessage, InferUITools } from "ai";
type MyUIMessageMetadata = {};
type MyDataPart = {};
type MyTools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<MyUIMessageMetadata, MyDataPart, MyTools>;
// Use MyUIMessage type when creating the UI message stream
const uiStream = result.toUIMessageStream<MyUIMessage>();
// Type-safe filtering with autocomplete
const stream = pipe<MyUIMessage>(uiStream)
.filter(includeParts(["text", "tool-weather"])) // Autocomplete works!
.map(({ chunk, part }) => {
// part.type is typed based on MyUIMessage
return chunk;
})
.toStream();The transformed stream has the same type as the original UI message stream. You can consume it with useChat() or readUIMessageStream().
Since message parts may be different on the client vs. the server, you may need to reconcile message parts when the client sends messages back to the server.
If you save messages to a database and configure useChat() to only send the last message, you can read existing messages from the database. This means the model will have access to all message parts, including filtered parts not available on the client.
input, aReadableStream<UIMessageChunk>or anAsyncIterable<UIMessageChunk>
Returns a ChunkPipeline with chainable operators. Pass your UIMessage type as pipe<MyUIMessage>(input) to type the chunk and part unions. The pipeline is itself an AsyncIterable, and it can only be consumed once.
const stream = pipe<MyUIMessage>(result.toUIMessageStream<MyUIMessage>())
.filter(excludeTools())
.toStream();Drop chunks from the stream. Pass a type guard to narrow the chunk and part types for every later operator, or a plain predicate receiving { chunk, part } and returning true to keep. A plain predicate may be async and is awaited. Meta chunks always pass through, so the callback only sees content chunks.
pipe<MyUIMessage>(stream).filter(includeParts(["text"]));
pipe<MyUIMessage>(stream).filter(({ chunk, part }) => part.type !== "reasoning");Transform chunks. The callback receives { chunk, part } and returns a chunk, an array of chunks, or null to drop it. The callback may be async, and is awaited before the chunk reaches any later operator, so ordering is preserved.
pipe<MyUIMessage>(stream).map(({ chunk }) => {
if (chunk.type === "text-delta") return { ...chunk, delta: chunk.delta.toUpperCase() };
return chunk;
});
// Inline a remote file as a data URL before it reaches the client
pipe<MyUIMessage>(stream).map(async ({ chunk }) => {
if (chunk.type !== "file") return chunk;
const base64 = await downloadAsBase64(chunk.url);
return { ...chunk, url: `data:${chunk.mediaType};base64,${base64}` };
});Observe chunks without changing the stream. Every chunk passes through regardless of whether the callback runs. Both the predicate and the callback may be async and are awaited; a throw propagates and fails the stream. For meta chunks part is undefined.
pipe<MyUIMessage>(stream).on(toolCall({ state: "output-available" }), async ({ chunk, part }) => {
await log(part.type, chunk.output);
});Execute the pipeline and return an AsyncIterableStream<UIMessageChunk>. Throws if the pipeline was already consumed.
const stream = pipe<MyUIMessage>(input).filter(excludeTools()).toStream();Filter guards matching chunks by chunk type. Accept a single type or an array. Meta chunks pass through either way.
pipe<MyUIMessage>(stream).filter(includeChunks("text-delta"));
pipe<MyUIMessage>(stream).filter(excludeChunks(["text-start", "text-end"]));Filter guards matching chunks by the part they belong to. Accept a single type or an array. A part type covers every chunk that builds it, so includeParts('text') keeps text-start, text-delta and text-end.
pipe<MyUIMessage>(stream).filter(includeParts(["text", "reasoning"]));
pipe<MyUIMessage>(stream).filter(excludeParts("tool-weather"));Filter guards matching tool chunks by tool name, without the tool- prefix. Called with no argument they match every tool, including dynamic ones. Non-tool chunks always pass through.
pipe<MyUIMessage>(stream).filter(excludeTools());
pipe<MyUIMessage>(stream).filter(excludeTools(["weather", "database"]));
pipe<MyUIMessage>(stream).filter(includeTools("weather"));Observe guards for .on(), matching by chunk type or by part type. Accept a single type or an array. chunkType matches meta chunks, partType does not.
pipe<MyUIMessage>(stream).on(chunkType(["start", "finish"]), ({ chunk }) => track(chunk.type));
pipe<MyUIMessage>(stream).on(partType("text"), ({ chunk }) => buffer(chunk));Observe guard for .on() matching tool state transitions. Streaming chunks such as tool-input-start and tool-input-delta are never matched.
options.tool(optional), a tool name without thetool-prefix (default: every tool)options.state(optional), one of'input-available','approval-requested','output-available','output-error','output-denied'(default: every state)
pipe<MyUIMessage>(stream).on(toolCall(), ({ chunk, part }) => log(part.type, chunk.type));
pipe<MyUIMessage>(stream).on(toolCall({ tool: "weather" }), ({ part }) => log(part.type));
pipe<MyUIMessage>(stream).on(
toolCall({ tool: "weather", state: "output-available" }),
({ chunk }) => log(chunk.output),
);A .map() callback that rewrites providerMetadata on the chunks that carry it and passes every other chunk through untouched. The callback receives { chunk, part, metadata }, where metadata may be undefined, and returns an object to set it, undefined to leave the chunk alone, or null to remove the field. null removes the field, not the chunk.
pipe<MyUIMessage>(stream).map(
transformProviderMetadata(({ chunk, part, metadata }) => {
if (part.type === "text") return null;
return { ...metadata, app: { traceId } };
}),
);stream, aReadableStream<UIMessageChunk>
Reads the stream to completion and resolves with the final assembled UIMessage. Throws if the stream ends without producing a message.
const message = await consumeUIMessageStream<MyUIMessage>(
pipe<MyUIMessage>(stream)
.filter(includeParts(["text"]))
.toStream(),
);