Skip to main content

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 }));
note

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 });
}
});

Try it