Translate an Amazon Bedrock Agent InvokeAgent response stream into an AI SDK UI message stream, so useChat works unchanged.
InvokeAgent answers with a 14-member AWS union: text chunks, traces, knowledge-base
citations, code-interpreter files, return-control payloads and ten typed exceptions.
useChat reads the AI SDK's UI message stream protocol. Neither SDK converts between
them, and vercel/ai#2081 has been open for
25 months, with the AI SDK maintainer's position being that this adapter layer belongs in
application code. This is that layer — and the single most useful thing in it is a
one-line request default you would have no reason to guess at, described
below.
npm install bedrock-ui-streamZero runtime dependencies. ai@^7 and @aws-sdk/client-bedrock-agent-runtime@^3.700.0
are peer dependencies — the two packages your app already has. Neither is bundled.
// app/api/chat/route.ts
import { BedrockAgentRuntimeClient, InvokeAgentCommand } from '@aws-sdk/client-bedrock-agent-runtime';
import { toInvokeAgentInput, toUIMessageStream } from 'bedrock-ui-stream';
import { createUIMessageStreamResponse } from 'ai';
const client = new BedrockAgentRuntimeClient({ region: process.env.AWS_REGION });
export async function POST(req: Request) {
const { id, messages } = await req.json();
const response = await client.send(new InvokeAgentCommand({
agentId: process.env.BEDROCK_AGENT_ID!,
agentAliasId: process.env.BEDROCK_AGENT_ALIAS_ID!,
sessionId: id, // one useChat chat === one Bedrock session
enableTrace: true, // the only way to learn the agent itself failed
...toInvokeAgentInput(messages), // -> { inputText, sessionState?, streamingConfigurations }
}));
return createUIMessageStreamResponse({ stream: toUIMessageStream(response) });
}The client is plain useChat, unchanged:
'use client';
import { useChat } from '@ai-sdk/react';
export function Chat({ chatId }: { chatId: string }) {
const { messages, sendMessage } = useChat({ id: chatId });
// ...
}Full runnable versions of both halves, plus tool calling and citation rendering, are in
examples/.
Bedrock keeps conversation history server-side, keyed on sessionId. You send one
inputText per turn, not the message array. toInvokeAgentInput exists to enforce
exactly that: it reads the newest user turn and ignores the rest of the history.
sessionId is conversation identity. This library never generates one for you — a
per-request sessionId makes the agent silently amnesiac, which looks like a model
quality problem and costs hours. useChat already sends a stable chat id; use it, and
persist it with the conversation rather than letting a remount regenerate it.
The translation itself is a morning's work. These are the things that cost the rest of the week.
toInvokeAgentInput returns streamingConfigurations: { streamFinalResponse: true }.
From the AWS API reference, on streamFinalResponse:
Specifies whether to enable streaming for the final response. This is set to false by default.
With it off — the service default — Bedrock buffers the entire answer and delivers it as a
single chunk at the very end. AWS's own InvokeAgent documentation says as much: "The
InvokeAgent returns one chunk for the entire interaction." A perfectly wired useChat
then shows nothing for ten seconds and then everything at once, and the reasonable
conclusion is that useChat streaming is broken.
That is almost certainly what people are hitting in issue #2081. Nothing in the request looks wrong, nothing errors, and the fix is a field most people never find. If you take one thing from this package, take this line.
It is overridable:
const input = toInvokeAgentInput(messages, {
streamingConfigurations: { streamFinalResponse: true, applyGuardrailInterval: 200 },
});applyGuardrailInterval defaults to 50 characters service-side. Pass
streamingConfigurations: false to omit the key entirely and inherit the service
defaults.
Important
Streaming needs an extra IAM permission, and this package turns streaming on by
default. Your agent's execution role needs bedrock:InvokeModelWithResponseStream
in addition to bedrock:InvokeModel — AWS documents this on the
streamingConfigurations field.
The failure mode is confusing: with the permission missing,
streamFinalResponse: true throws AccessDeniedException while false keeps working,
so it looks like the flag itself is broken. See
aws-sdk-js-v3#6712.
If your agent worked before you installed this package and now returns 403, this is
why. Grant the permission, or pass streamingConfigurations: false to opt out. The
AccessDeniedException message names this cause first.
It reliably turns one chunk into many. It does not guarantee tokens arrive as they are generated:
- Chunk counts vary enormously by model on the same agent and prompt. Reported in aws-sdk-js-v3#6712: Sonnet 3 → 374 chunks, Haiku 3 → 107, Sonnet 3.5 v2 → 50, Nova Lite → 27, Haiku 3.5 → 4, Claude Instant → 2.
- Delivery can still arrive as a burst. boto3#4744 — open, reproduced by AWS — has trace at 1.33 s and the first chunk at 7.18 s, with every remaining chunk inside 10 ms.
Expect the response shape to change. Measure before you promise anyone lower time-to-first-token.
Agents created before 2025-03-31 have a further wrinkle: AWS briefly documented that streaming required orchestration-only prompts, and users with knowledge-base-equipped agents in that window reported collapsing back to a single chunk. That precondition is no longer in the service model.
A multi-byte character can straddle two events. This library holds one
TextDecoder({ stream: true }) across the whole stream. A per-chunk
new TextDecoder().decode() returns U+FFFD for any split character — invisible in
English-language testing, and reported later as "the library corrupts emoji". There is a
test for it.
When the agent fails but the HTTP stream stays healthy, Bedrock sends a failureTrace
and ends the stream. No exception, no answer. This library turns it into an error chunk
unconditionally, so it cannot be silenced by the trace options — but it only reaches you
if the request set enableTrace: true.
Action groups configured with RETURN_CONTROL pause the agent mid-turn. Resuming requires
sending back invocationId and a result shaped per invocation kind, on a request with no
inputText — Bedrock takes the results instead of a new user turn. The API reference is
explicit that a request carrying both is not an error but a silent discard: "If you include
returnControlInvocationResults in the sessionState field, the inputText field will be
ignored." Which is worse than an error, because a resuming turn that also carries user text
loses that text with no signal — so toInvokeAgentInput omits inputText entirely rather
than sending an empty string. That state has to survive a browser reload, so this library
persists it in the message part itself and reads it back. See
Return control.
ai masks stream errors as 'An error occurred.' by default. A masked Bedrock error is
undebuggable, so this library builds its ReadableStream directly rather than wrapping
createUIMessageStream({ execute }), specifically so masking cannot happen. You get AWS's
message verbatim plus a remediation clause, and an onError hook to redact it.
-
Bedrock AgentCore (
InvokeAgentRuntime). Out of scope, and no general adapter can exist —InvokeAgentRuntimereturns opaque bytes, not a modeled union. The reasoning is in AgentCore is out of scope. -
An AgentCore agent that already emits AI SDK chunks. There is nothing to translate. Pass your stream straight to
createUIMessageStreamResponseand skip this package:return createUIMessageStreamResponse({ stream: yourAgentCoreStream });
-
A non-
useChatfrontend. The output is the AI SDK UI message stream protocol specifically. If your client reads raw text or your own JSON envelope, iterateresponse.completionyourself — you do not need an adapter to a protocol you are not using. -
Bedrock Runtime rather than Bedrock Agents. For
Converse/InvokeModeluse@ai-sdk/amazon-bedrock, which is a real AI SDK provider. This package only translatesInvokeAgent.
Five values and eight types, all from the package root. Everything else is internal, and
deep imports are blocked at module resolution — exports declares no wildcard subpaths.
Takes the whole InvokeAgentCommandOutput (or anything with a completion) and returns a
ReadableStream of UI message chunks. It takes the wrapper rather than the bare iterable
because completion is typed AsyncIterable<ResponseStream> | undefined, so passing
response.completion would fail tsc --strict and every app would open with
response.completion!.
| Option | Default | Notes |
|---|---|---|
sendReasoning |
true |
orchestrationTrace.rationale → reasoning-* parts. Needs enableTrace. |
sendSources |
true |
chunk.attribution → source-url / source-document parts. |
sendFiles |
true |
files events → file parts as data: URLs. See Files. |
sendStart |
true |
Emit the leading start chunk. Set false when merging into another stream. |
sendFinish |
true |
Emit the trailing finish chunk. Set false when merging into another stream. |
sendTrace |
false |
Emit transient data-bedrock-trace parts. |
onError |
built-in | Map a failure to errorText. |
onEvent |
— | Observe every raw ResponseStream event before translation. |
generateId |
crypto.randomUUID |
IDs for text parts, sources and tool calls. |
Derives { inputText?, sessionState?, streamingConfigurations? } from useChat history.
Spread it into your own command input. It does two things: sends one user turn rather than
the array, and rebuilds the return-control round trip when history contains answered
dynamic-tool parts.
Its one option is streamingConfigurations, covered
above.
A Request -> Response handler, for when you have no credential chain, region strategy or
sessionAttributes to place:
// app/api/chat/route.ts
import { BedrockAgentRuntimeClient } from '@aws-sdk/client-bedrock-agent-runtime';
import { createAgentRouteHandler } from 'bedrock-ui-stream';
export const POST = createAgentRouteHandler({
client: new BedrockAgentRuntimeClient({ region: process.env.AWS_REGION }),
agentId: process.env.BEDROCK_AGENT_ID!,
agentAliasId: process.env.BEDROCK_AGENT_ALIAS_ID!,
});| Option | Default | Notes |
|---|---|---|
client |
— | Required. Yours, so your credentials, region and retry strategy all apply. |
agentId / agentAliasId |
— | Required. |
enableTrace |
true |
See below. |
sessionId |
body.id |
(body, request) => string. |
prepareInput |
— | Last look at the assembled command input. |
streamOptions |
— | Passed through to toUIMessageStream. |
headers |
— | Merged over the UI-message-stream response defaults. |
prepareInput is where sessionState.sessionAttributes, memoryId, endSession and
bedrockModelConfigurations go:
export const POST = createAgentRouteHandler({
client,
agentId: process.env.BEDROCK_AGENT_ID!,
agentAliasId: process.env.BEDROCK_AGENT_ALIAS_ID!,
prepareInput: ({ input }) => ({
...input,
memoryId: 'mem-1',
sessionState: { ...input.sessionState, sessionAttributes: { tier: 'gold' } },
}),
});failureTrace is the only signal that the agent failed while the HTTP stream stayed
healthy, and it arrives only with tracing on. Without it, that failure mode is invisible:
the stream just ends with no answer and no exception.
The cost is bandwidth on the AWS → your-server hop. Trace payloads are large —
modelInvocationInput carries the whole rendered prompt — and they cross that hop even
when sendTrace: false keeps them out of the browser. Set enableTrace: false if you
have measured that cost and would rather have silent agent failures.
The rule: construction throws, streaming emits.
toUIMessageStream throws BedrockUIStreamError only for caller mistakes detectable
before any byte reaches the client. Once it has returned a stream it never throws —
every failure becomes an { type: 'error', errorText } chunk, the only error channel
the wire protocol has.
BedrockUIStreamError codes: no_completion_stream, no_user_message,
invalid_tool_output. Use the static BedrockUIStreamError.isInstance(error) rather than
instanceof; it survives two copies of this package in one dependency tree.
The default errorText is Bedrock <ExceptionName>: <AWS message> <remediation> — AWS's
message verbatim, plus one library-added remediation clause per exception type. To redact
before anything reaches the browser:
const stream = toUIMessageStream(response, {
onError: (error) => {
console.error(error); // full detail stays server-side
return 'The assistant could not complete that request.';
},
});In createAgentRouteHandler, pre-stream failures become real HTTP status codes so
useChat populates its error state properly: $fault: 'client' → 400,
'server' → 502, throttling → 429 with retry-after, access denied → 403,
not found → 404.
Two deliberate cases:
failureTrace→ anerrorchunk. Never suppressible, and not gated behindsendTrace. RequiresenableTrace: trueon the request to arrive at all.$unknown→ ignored, never thrown. AWS addsResponseStreammembers without a major version bump; throwing would break working apps on a routine SDK upgrade.
| Export | Kind |
|---|---|
toUIMessageStream |
function |
toInvokeAgentInput |
function |
createAgentRouteHandler |
function |
BedrockUIStreamError |
class, with isInstance |
BedrockAgentFailureError |
class — what failureTrace is wrapped in before onError sees it |
ToUIMessageStreamOptions, InvokeAgentInput, ToInvokeAgentInputOptions |
types |
AgentRouteHandlerConfig, AgentRouteHandlerRequestBody |
types |
BedrockReturnControlMetadata, BedrockTraceData |
types |
BedrockUIStreamErrorCode |
type |
Four features that need more than an option name to use well.
Action groups are configured in AWS, not declared in TypeScript, so their names are
unknowable at compile time. returnControl events therefore become dynamic-tool
parts, the AI SDK's construct for runtime-discovered tools, carrying a
toolMetadata: BedrockReturnControlMetadata that drives the round trip.
Bedrock's FunctionParameter is a name/type/value list where value is always a
string regardless of the declared type. This library pivots it into an object and
coerces per Bedrock's ParameterType vocabulary (string, number, integer,
boolean, array), falling back to the raw string for anything unrecognised rather
than mangling it.
toInvokeAgentInput reads that metadata back off persisted parts:
const history = [
{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'Where is order A-1001?' }] },
{
id: 'a1',
role: 'assistant',
parts: [{
type: 'dynamic-tool',
toolName: call.toolName,
toolCallId: call.toolCallId,
state: 'output-available',
input: call.input,
output: { status: 'shipped' },
toolMetadata: call.toolMetadata,
}],
},
];
const input = toInvokeAgentInput(history);
// input.inputText -> undefined
// input.sessionState -> {
// invocationId: 'inv-abc',
// returnControlInvocationResults: [{
// functionResult: {
// actionGroup: 'OrderActions',
// function: 'lookupOrder',
// responseBody: { TEXT: { body: '{"status":"shipped"}' } },
// },
// }],
// }On the client, useChat's onToolCall plus addToolOutput and
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls close the loop with no
extra plumbing — see examples/return-control.
Two things worth knowing:
- Tool output goes back as
ContentBody.body, which is a string — objects are JSON-stringified, conventionally under the key"TEXT". - No
inputTextis sent on a resuming turn. Only results belonging to the newestinvocationIdare returned; older ones were already consumed.
A part still in input-available state (output not yet supplied) is not treated as an
answer, so the request falls back to normal inputText behaviour. A part in
output-error state is sent back as a FAILURE result, so the agent can apologise
instead of waiting for a result that never comes.
chunk.attribution is not a standalone event — it is metadata about a byte range of
text already emitted. Each retrievedReference becomes one source part:
- Locations with a URL (
webLocation,confluenceLocation,salesforceLocation,sharePointLocation,oneDriveLocation,googleDriveLocation, and HTTP Kendra URIs) →source-url. s3Location, non-HTTP Kendra URIs,customDocumentLocation,sqlLocationand anything unrecognised →source-document, with a synthesisedtitleand a guessedmediaType(both are required, non-optional fields on that chunk).
There is no de-duplication: a reference cited twice yields two source parts, each with
its own span. Group on providerMetadata.bedrock.location if you want them merged —
examples/traces-and-citations does.
providerMetadata.bedrock carries span (raw, exactly as AWS sent it), resolvedSpan,
chunkTextOffset, citedText, location, content and metadata. See
Known assumptions on spans.
const stream = toUIMessageStream(response, { sendTrace: true });Trace parts are data-bedrock-trace and carry transient: true, so they reach the
client for live rendering but stay out of persisted history. Trace payloads are large
and re-POSTing them every turn would be wasteful. The part name is semver-protected;
the payload is BedrockTraceData.
Declare the part in your own UIMessage type and the client gets it typed rather than
unknown:
import type { UIMessage } from 'ai';
import type { BedrockTraceData } from 'bedrock-ui-stream';
export type BedrockUIMessage = UIMessage<never, { 'bedrock-trace': BedrockTraceData }>;Then toUIMessageStream<BedrockUIMessage>(response, { sendTrace: true }) on the server and
useChat<BedrockUIMessage>() on the client.
file chunks require a URL and Bedrock hands over raw bytes, so bytes become data:
URLs. Those persist into message history and get re-POSTed on every subsequent turn.
If your agent's code interpreter emits megabyte-scale output, turn it off and upload
yourself:
const stream = toUIMessageStream(response, {
sendFiles: false,
onEvent: async (event) => {
for (const file of event.files?.files ?? []) {
if (file.bytes === undefined) continue;
await uploadToS3(file.name ?? 'untitled', file.bytes);
}
},
});onEvent is the general escape hatch for anything this library does not map: metering
token usage from modelInvocationOutput, structured logging, custom trace handling. A
thrown error or rejected promise there is swallowed — an observer must not be able to
corrupt the answer stream.
InvokeAgent returns a modeled union: 14 typed members, a generated visitor, and
typed error classes. That is a schema, and it can be faithfully translated. This package
does exactly that.
InvokeAgentRuntime returns response?: StreamingBlobPayloadOutputTypes — opaque
bytes, whose documented contract is that the format of this data depends on the
specific agent configuration and the requested accept type. No union, no members, no
error taxonomy. The string text/event-stream does not appear anywhere in the package.
You send arbitrary bytes to a container you wrote and get arbitrary bytes back.
There is no schema to adapt. The two APIs cannot share an adapter because there is
no common type to write a function against. An AgentCore entry point would be a thin
wrapper around a decode callback you have to write anyway — a public surface whose
correctness depends on a convention this library cannot see, test or version.
If your AgentCore container already emits AI SDK UI message chunks, there is nothing to translate:
return createUIMessageStreamResponse({ stream: yourAgentCoreStream });bedrock-ui-stream/agentcore is reserved in the exports map and deliberately
unimplemented. If demand appears it will ship with a required decode option under
a different name — createAgentCoreStream, because "translate a known event type" is
not what it would do.
Flagged rather than buried, because they are unverified against production traffic.
Citation span offsets are assumed cumulative. AWS documents Span.start/end
only as "where the text with a citation starts/ends in the generated output". This
library reads that as offsets into the full generated response and tracks a running
character offset accordingly. Both readings are recoverable from
providerMetadata.bedrock: span is the raw value untouched, resolvedSpan is the
cumulative interpretation, and chunkTextOffset is where the carrying chunk's text
began — add it to span.start for the chunk-relative reading instead.
chunk.attribution may or may not accompany bytes. Both shapes are handled: a
chunk with both emits text then sources; an attribution-only chunk emits sources against
the current offset. Nothing requires one shape.
If you can confirm or refute either from production traffic, an issue saying so is one of the most useful contributions available.
Semver-protected: the five exported values and the public types; option names and
defaults; BedrockUIStreamError codes; the BedrockReturnControlMetadata shape (it
round-trips through persisted history, so it is a wire format); the
data-bedrock-trace part name; and the guarantee that toUIMessageStream never throws
after returning.
Explicitly internal: anything not exported from the root; deep import paths (blocked
at module resolution — no wildcard subpaths in exports); the exact errorText prose
(the taxonomy is stable, the wording is not — do not regex it); the source-url vs
source-document heuristics, mediaType guessing and FunctionParameter.type coercion;
and the contents of providerMetadata.bedrock.
Peers, not dependencies: ai@^7 and @aws-sdk/client-bedrock-agent-runtime. Two
copies of either in one build would be a real bug. A future ai@8 ships as a new major
here, not a widened range.
Note for CommonJS consumers.
ai@7is ESM-only. Both peers are loaded through a lazy dynamicimport(), sorequire('bedrock-ui-stream')works everywhere andtoUIMessageStream/toInvokeAgentInputneed no ESM interop at all. OnlycreateAgentRouteHandlertouches the peers, and it is already async.
| Example | What it shows |
|---|---|
nextjs-route-handler |
The smallest complete endpoint, plus the useChat client. |
return-control |
A full tool round trip: server, action group implementation, and the useChat half. |
traces-and-citations |
Reasoning, live traces and knowledge-base citations, with a typed trace data part. |
Every example is executed by the test suite, so it cannot drift from the library.
Issues and pull requests are welcome, including from first-time contributors — see CONTRIBUTING.md for setup, layout and what a good PR looks like. Production reports about the two known assumptions are especially valuable, because they cannot be resolved from the AWS documentation alone.
MIT © Rizwan Saleem. See LICENSE.