New Streaming forms in v4.1

The web framework that streams.

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
Get started
app/inbox/page.kb
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} />
  }
}
First byte
38 ms
Runtime
9.6 kB
Loading spinners
0
Weekly installs
1.2 M

Line by line

Pages arrive the way text arrived over a modem.

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.

  1. 0 ms

    Request

    The router matches the page and starts its generator. Headers and the shell leave right away.

  2. 38 ms

    First line

    The header renders while your queries run. The browser paints it before the database answers.

  3. 40–400 ms

    The rest, in order

    Each row streams as its data lands. Slow parts never hold fast parts hostage.

  4. Forever

    Live

    Wrap a query in live() and changes stream down one shared socket. No client state library.

Why Kilobaud

Everything a fast page needs. Nothing it doesn't.

Streams by default

Components are async generators. Yield markup as soon as you have it, and it goes down the wire in order.

Zero-JS pages

Pages ship no JavaScript until an island asks for it. Most of them never do.

Live queries

Wrap any query in live() and the page stays current over one shared socket.

Works at 300 baud

Forms post without JavaScript and resume after a dropped connection. Your app slows down like a modem, but it never breaks.

Types end to end

Loaders, actions and routes are typed from the database to the markup. Rename a column and the compiler finds every line.

Deploys anywhere

Node, Bun, Deno or the edge. One adapter, no lock-in, and no config file longer than this sentence.

Benchmarks

Time to first line

A mid-range phone on a slow 3G connection, loading a 200-row inbox. Lower is better.

  • Kilobaud 4.10.41 s
  • Server-rendered SPA1.9 s
  • Client-rendered SPA3.6 s
We deleted 212 kB of client JavaScript and conversions went up 9%. The spinners were the product, apparently.
Mira Castellanos Staff engineer, Parcelwise
It feels like the web in 1996, except fast. I mean that as the highest compliment.
Dev Okafor Indie hacker
Our dashboards stream forty live panels over one socket. Ops thinks it's magic. It's a generator.
Hanna Virtanen Platform lead, Tidewater Grid

Community

Questions? The sysop is in.

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

A custom chat UI on Stand's Visitor API

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.

Try it

  1. Watch the monitor in the corner for a moment. It turns towards your pointer, tilts as you scroll, and now and then draws its sysop in characters.
  2. Move your pointer close to it, or tap it. It fills the screen, dials, and types its greeting at 1200 baud.
  3. Ask something, or play along: LOOK, INVENTORY and XYZZY all work. /help lists the terminal's own commands, like /baud 9600 and /bye.
  4. Reload the page mid-conversation. The chat comes back, and replies sent meanwhile arrive.
  5. Click outside the monitor or press Esc to send it back to its corner.
This example uses 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.

1. Discover who can 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, … }

2. Create the conversation on the first message

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}

3. Send over HTTP, receive over a WebSocket

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

4. Print it at 1200 baud

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

The files

Good to know

How this was made

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

  • Link the custom chat UI guide. It is written as a contract for coding agents, with a brief to paste.
  • Start on the demo Site ID, so the agent can hold real conversations while it builds and tests, then switch to yours.
  • Let the agent tune the Stand-in's voice through prompt, and have it test a few variants for real. Here, a sysop who answers first and jokes second won.
  • Give it reference images, a device to target and a frame rate, and ask it to check its own work in a browser.

Copy this example

npx degit standchat/examples/vintage-terminal my-terminal