Streams by default
Components are async generators. Yield markup as soon as you have it, and it goes down the wire in order.
Kilobaud renders on the server and streams your interface to the browser as fast as your data arrives. The first line in milliseconds, the rest as it's ready. No spinners, no waterfalls, no megabytes of JavaScript.
npm create kilobaud@latest
import { live } from 'kilobaud'
// Every yield is flushed to the browser.
export default async function* Inbox({ db }) {
yield <Header title="Inbox" />
for await (const thread of live(db.threads.recent())) {
yield <Thread key={thread.id} {...thread} />
}
}
Line by line
A Kilobaud page is an async generator. Every yield flushes to the browser, so people read the first line while your server is still fetching the tenth.
The router matches the page and starts its generator. Headers and the shell leave right away.
The header renders while your queries run. The browser paints it before the database answers.
Each row streams as its data lands. Slow parts never hold fast parts hostage.
Wrap a query in live() and changes stream down one shared socket. No client state library.
Why Kilobaud
Components are async generators. Yield markup as soon as you have it, and it goes down the wire in order.
Pages ship no JavaScript until an island asks for it. Most of them never do.
Wrap any query in live() and the page stays current over one shared socket.
Forms post without JavaScript and resume after a dropped connection. Your app slows down like a modem, but it never breaks.
Loaders, actions and routes are typed from the database to the markup. Rename a column and the compiler finds every line.
Node, Bun, Deno or the edge. One adapter, no lock-in, and no config file longer than this sentence.
Benchmarks
A mid-range phone on a slow 3G connection, loading a 200-row inbox. Lower is better.
We deleted 212 kB of client JavaScript and conversions went up 9%. The spinners were the product, apparently.
It feels like the web in 1996, except fast. I mean that as the highest compliment.
Our dashboards stream forty live panels over one socket. Ops thinks it's magic. It's a generator.
Community
Our maintainers, and a very well-read AI, answer on the Kilobaud BBS. Walk up to the terminal in the corner, or dial in from here.
How it works
Stand's widget is optional. This terminal is a complete Stand chat built from scratch: it asks Stand who can answer, starts a conversation with the visitor's first message, sends over HTTP, listens on a WebSocket, and recovers after reloads and dropped connections. Everything else is presentation.
LOOK, INVENTORY and XYZZY all work. /help lists the terminal's own commands, like /baud 9600 and /bye.siteId: 'demo', Stand Chat's shared demo site, which works on any domain. Its demo Stand-in follows the terminal's greeting and prompt, but won't speak for the made-up framework. With your own Site ID, your own Stand-ins answer.Before anything is created, one public request asks Stand for an available responder on this page. It returns their name, avatar, greeting, and the attribution to show. If nobody can answer, the modem says BUSY.
const query = new URLSearchParams({ siteId, page: location.href, greetingsEnabled: 'false' });
const offer = await (await fetch(`https://api.stand.chat/v1/reps/find?${query}`, { credentials: 'omit' })).json();
// { available, responderType, standinProfileId | repId, repName, avatar, showId, poweredByUrl, … }
Only a visitor's message creates a session. The greeting the terminal typed goes along as the opening message, and prompt gives the AI Stand-in its sysop persona. The response carries a visitor token for everything that follows.
const session = await post('/v1/sessions', {
siteId, page: location.href,
standinProfileId: offer.standinProfileId, // or repId for a person
initialMessage: text,
includeOpeningGreeting: true, openingMessage: greeting,
prompt: 'You are the sysop on duty at the Kilobaud BBS… Plain text only, under 300 characters.',
showId: offer.showId,
});
// Later requests send Authorization: Bearer ${session.visitorToken}
Each send carries a clientMessageId, so a retry after a dropped connection can never post twice. Replies, typing, handoffs and the end of the chat arrive on the socket. After it connects, one snapshot fills any gap, and everything merges by messageId in seq order.
const pending = { body: text, type: 'text', clientMessageId: crypto.randomUUID() };
await post(`/v1/sessions/${sessionId}/messages`, pending); // retry with the same object
const socket = new WebSocket(`wss://api.stand.chat/ws/sessions/${sessionId}?token=${token}`);
socket.onmessage = ({ data }) => {
const frame = JSON.parse(data);
if (frame.type === 'connected') refreshSnapshot(); // covers messages sent meanwhile
else if (frame.type === 'session.closed') finish(); // NO CARRIER
else if (frame.event === 'message') merge([frame]);
};
The terminal never draws text directly. screen.js queues styled text and reveals it at a modem's pace (baud / 10 characters per second), crt.js turns the cells into a video signal through a character ROM, and the glass draws each scanline as an electron beam, with phosphor afterglow and a soft halo.
// 8 data bits, a start bit and a stop bit: 1200 baud is 120 characters a second.
const cps = block.cps ?? this.baud / 10;
while (count < block.chars.length && this.#budget > 0) {
this.#budget -= (block.chars[count].ch === '\n' ? 2 : 1) / cps; // CR + LF
count++;
}
stand-client.js is the part to copy: discovery, one-time creation, idempotent sends, WebSocket receive, recovery, handoffs, follow-ups and link clicks, with no framework and no UI.bbs.js is the chat program: IRC-style lines, CONNECT, BUSY and NO CARRIER, commands, and the portrait loop.screen.js and rom.js are the text mode: 80×25 cells (40×25 on phones) of VT323 glyphs, sampled onto a 10 × 12 dot grid.crt.js is three.js: the monitor from monitor.glb, the phosphor passes and the glass shader.monitor.py builds monitor.glb in headless Blender, from an empty scene to baked textures, in about a minute. Change the monitor there, not in the GLB.terminal.js puts it on the page: the corner, the zoom, pointer and scroll motion, the keyboard, and a screen-reader log of the conversation.Access-Control-Allow-Origin. Without it, and for responders with no avatar, the terminal draws Stand's logo.This example is nearly a one-shot. Claude Opus 5.5, with max effort, in Claude Code, built it in one session from the prompt below and one follow-up about the Stand-in's personality. It read the custom chat UI guide, ran real conversations against Stand's demo site in headless Chrome, and iterated on its own screenshots. A sub-agent modeled the monitor in parallel, from a written brief, as the Blender script monitor.py.
Now that AI writes the front end, a chat that belongs to your site's world is a prompt away. Stand's Visitor API keeps the conversation, the AI Stand-in and your team behind it.
The prompt, lightly edited
Let's add a super fancy example: Vintage Terminal. A completely custom chat UI that looks like an old terminal: glowing green characters, 80×25, a blinking cursor. I'm adding a couple of images for inspiration. Model the CRT monitor the terminal runs on in 3D. You have Blender (with its MCP server) on this machine. It should be photorealistic. For the website itself, be creative. It could be a developer website for a framework; that isn't very important. The 3D monitor sits in the corner of the website, subtly turning towards the mouse cursor and tilting while you scroll. Make it VERY subtle, but enough to show that it's a 3D element. When someone moves the mouse close to the terminal, or taps it, it grows to fill the screen and the chat greeting is typed on the screen. All messages appear as if they came over a 1200 baud modem, character by character. A blinking cursor invites the visitor to type. Clicking outside the monitor minimizes it back into the corner. When the screen isn't zoomed in, the responder's avatar is sometimes printed on it as ASCII graphics, again character by character like on an old BBS, and then scrolled away. This is to catch attention. If there is no avatar image, use the Stand logo, again as ASCII graphics. It's a fully functional chat, with all the functions from https://stand.chat/guide/custom-chat-ui. The target is modern browsers on up-to-date hardware: assume an iPhone 16 Pro or newer. Use 3D web graphics and shader effects to make it realistic. It should run at 60 fps, so keep the model's complexity and the number of shaders under control. If you need a style balance between IBM, cyberpunk and Fallout, lean towards fiction over boring realism. This is supposed to be a fun chat widget that developers visiting the site immediately fall in love with. Be creative and pay deep attention to detail. Take your time to make this perfect. [Five reference photos of green-screen terminals]
The follow-up
You can ask the Stand-in to behave a bit like a CLI or an old text adventure with a custom prompt. Test what gives a good balance between goofiness and actual usability.
If you try this yourself
demo Site ID, so the agent can hold real conversations while it builds and tests, then switch to yours.prompt, and have it test a few variants for real. Here, a sysop who answers first and jokes second won.npx degit standchat/examples/vintage-terminal my-terminal