LLM components
Backroad ships components tuned for chat-style LLM frontends:
br.chatInput, br.chatMessage, and br.loading.
br.chatInput
A text input that returns the submitted message on the run after the
user hits enter, then null on subsequent runs until the next submit.
const message = br.chatInput({ placeholder: 'Ask me anything…' });
if (message) {
// run an LLM call, store it in your message log, …
}
To keep the input pinned to the bottom of the page while the conversation
scrolls above it, dock it in br.bottom():
const message = br.bottom().chatInput({ id: 'chat' });
The dock renders outside the scrolling body, so a single render is enough — you don't have to re-emit the input below the newest message to keep it at the bottom.
br.chatMessage
A speech-bubble container. Like other containers, it returns a manager
you can call .write / .image / etc. on:
const msg = br.chatMessage({
by: 'Assistant',
avatar: '/bot.png',
avatarPlacement: 'left',
});
msg.write({ body: 'Hello, human.' });
Streaming replies
Most LLMs stream their output token by token. writeStream pumps any
AsyncIterable<string> into a single bubble, re-rendering the accumulated
text as each chunk arrives, so the message fills in live instead of appearing
all at once. It returns the complete string when the stream ends — so your
script owns the history: append the result yourself.
// Map your provider's stream to an async iterable of text chunks.
async function* tokens(prompt: string) {
const stream = await openai.responses.create({
model: 'gpt-4o',
input: prompt,
stream: true,
});
for await (const event of stream) {
if (event.type === 'response.output_text.delta') yield event.delta;
}
}
const reply = await br
.chatMessage({ by: 'Assistant' })
.writeStream(tokens(prompt));
// `reply` is the full text — persist it however you store history.
This is the analog of Streamlit's st.write_stream. The Vercel AI SDK's
textStream is already an AsyncIterable<string>, so you can pass it directly.
When you need to drive the bubble by hand — interleaving reasoning and answer,
or writing from SDK callbacks rather than an iterable — use streamable, which
returns an update(body) you call yourself:
const { update } = br.chatMessage({ by: 'Assistant' }).streamable();
let text = '';
onToken((chunk) => update((text += chunk)));
Async assistant replies (all at once)
If you'd rather render the reply in one shot, pass a Promise<string> as
loadingPromise — Backroad shows a spinner that swaps to the message once the
promise resolves:
const reply = openai.responses
.create({
/* … */
})
.then((r) => r.output_text);
const bubble = br.chatMessage({
by: 'Assistant',
loadingPromise: reply,
});
reply.then((text) => bubble.write({ body: text }));
ChatManager is deprecated. Let your script own the message history (as
above) and stream the AI turn with writeStream instead.
br.loading
A standalone spinner — handy outside of chat:
br.loading({ fontSize: 24 });
Putting it together
import { run } from '@backroad/backroad';
const history: { by: 'human' | 'ai'; content: string }[] = [];
// Stand-in for a real LLM stream; swap for your provider's token stream.
async function* reply(prompt: string) {
for (const word of `You said: ${prompt}`.split(' ')) {
await new Promise((r) => setTimeout(r, 60));
yield word + ' ';
}
}
run(async (br) => {
for (const m of history) {
br.chatMessage({ by: m.by }).write({ body: m.content });
}
const next = br.bottom().chatInput({ placeholder: 'Say something…' });
if (next) {
br.chatMessage({ by: 'human' }).write({ body: next });
const text = await br.chatMessage({ by: 'ai' }).writeStream(reply(next));
history.push({ by: 'human', content: next }, { by: 'ai', content: text });
}
});