bedrock-ui-stream
v0.1.1
Published
Translate an Amazon Bedrock Agent InvokeAgent response stream into an AI SDK UI message stream, for use with useChat.
Downloads
272
Maintainers
Readme
bedrock-ui-stream
Translate an Amazon Bedrock Agent InvokeAgent response stream into an AI SDK UI message stream, so useChat works unchanged.
The problem
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.
Install
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.
The working example
// 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/.
The mental model that trips everyone up
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.
Why this instead of your own adapter
The translation itself is a morning's work. These are the things that cost the rest of the week.
streamFinalResponse is false service-side, and that is why your stream looks broken
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:InvokeModelWithResponseStreamin addition tobedrock:InvokeModel— AWS documents this on thestreamingConfigurationsfield.The failure mode is confusing: with the permission missing,
streamFinalResponse: truethrowsAccessDeniedExceptionwhilefalsekeeps 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: falseto opt out. TheAccessDeniedExceptionmessage names this cause first.
What the flag does and does not fix
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.
chunk.bytes boundaries are not UTF-8 codepoint boundaries
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.
failureTrace is the only signal that the agent failed
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.
Return control is a wire format, not a callback
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 and rejects a request
carrying both. 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.
AWS's error text reaches your console
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.
When not to use this
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.
API reference
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.
toUIMessageStream(response, options?)
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. |
toInvokeAgentInput(messages, options?)
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.
createAgentRouteHandler(config)
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' } },
}),
});enableTrace defaults to true here
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.
Errors
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.
Exports
| 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 |
Advanced
Four features that need more than an option name to use well.
Return control (action groups you run yourself)
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.
Citations
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.
Traces
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.
Files, and why you may not want them
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.
AgentCore is out of scope, and no general adapter can exist
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.
Known assumptions
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.
Stability
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.
Examples
| 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.
Contributing
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.
License
MIT © Rizwan Saleem. See LICENSE.
