An embeddable chat widget for talking with your site's visitors, plus a self-hosted inbox for answering them. Think a small, self-hosted Intercom for Next.js sites on Vercel.
- Widget (
hailbox, published as GitHub releases): a floating button that opens a chat panel. Drop<Hailbox />into your app's layout. - Inbox (
apps/inbox): a separate Next.js app on Vercel, where your team signs in and replies. It also hosts the API the widget talks to. - Returning visitors see replies that arrived while they were away: an unread badge and a preview on the launcher, and the full conversation when they open it.
- Email notifications via Mailgun, if the visitor leaves an address and hasn't seen a reply within a minute.
- Slack forwarding: one thread per conversation, including your team's replies.
- Realtime over Vercel's WebSocket support, with Postgres
LISTEN/NOTIFYfanning events out across instances.
packages/hailbox The widget (React), plus hailbox/protocol (wire types) and hailbox/client (headless API client)
apps/inbox Operator inbox + API + WebSockets (Next.js, Postgres via Drizzle)
apps/demo A stand-in "customer site" that embeds the widget
e2e Playwright tests that drive the demo and the inbox together
You need Node 20+, pnpm, and a local Postgres server.
pnpm install
pnpm --filter hailbox build # the apps consume the widget's build output
pnpm -C apps/inbox db:migrate # creates the `hailbox` database if needed
pnpm dev # widget (watch), inbox on :3000, demo site on :3001Open http://localhost:3001 and send a message from the widget, then reply from the inbox at
http://localhost:3000. Without Mailgun configured, the sign-in page shows the magic link on
screen and outgoing emails are printed to the inbox's console. Without OPERATOR_EMAILS, any
email address can sign in. That only applies in development; in production an empty
allowlist locks everyone out.
The inbox's pnpm dev runs dev-server.ts rather than next dev. It provides the request
context that upgradeWebSocket() needs, so the WebSocket routes work locally. Vercel's docs
suggest vercel dev instead, but that needs a linked project.
pnpm test # unit + integration tests (the inbox's run against a scratch `hailbox_test` database)
pnpm test:e2e # Playwright: a visitor and an operator chatting in real browsers (uses your installed Chrome)The widget is published as a GitHub release rather than to npm. Add the tarball of the
latest release to your package.json
dependencies (its release notes have the exact line), then install:
"hailbox": "https://github.com/LessWrong2/hailbox/releases/download/v0.1.0/hailbox-0.1.0.tgz"// app/layout.tsx
import { Hailbox } from 'hailbox';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Hailbox
apiUrl="https://inbox.example.com"
title="Talk to us"
subtitle="We usually reply within a few hours."
greeting="Hi! What can we help with?"
/>
</body>
</html>
);
}<Hailbox /> is a client component, so you can render it from a server layout. See
packages/hailbox/README.md for all props, openHailbox(), and
the headless client.
Every merge to main that touches packages/hailbox publishes a release automatically
(.github/workflows/release-widget.yml): it typechecks, tests and builds the widget, then
attaches the packed tarball to a new release tagged with the next patch version. To start a new
minor or major series, raise the version in packages/hailbox/package.json; the next release
uses it as-is. Sites upgrade by pointing their dependency at the new release's URL.
- Create a Vercel project from this repository with Root Directory set to
apps/inbox.apps/inbox/vercel.jsonbuilds the widget package, applies database migrations, and builds the app. Migrations run only for production deploys, unless you setHAILBOX_MIGRATE_PREVIEWS=1because each preview has its own database branch. - Add Postgres (e.g. Neon) from the Vercel Marketplace and connect it to the project. This
sets
DATABASE_URL. Realtime updates also need a direct connection, becauseLISTENdoesn't work through a connection pooler. Neon's integration setsDATABASE_URL_UNPOOLEDfor this. With another provider, setDATABASE_URL_UNPOOLEDyourself ifDATABASE_URLis pooled. - Set environment variables (all are listed in
apps/inbox/.env.example):HAILBOX_SECRET: a long random string (openssl rand -base64 32).OPERATOR_EMAILS: comma-separated addresses allowed to sign in.HAILBOX_ALLOWED_ORIGINS: your site's origin(s), e.g.https://example.com. Reply emails only link back to pages on these origins, because visitors report their own page URL.HAILBOX_SITE_NAME: shown in emails, e.g.Acme.HAILBOX_INBOX_URL: the inbox's public URL, if it's not the Vercel production URL.
- Mailgun: set
MAILGUN_API_KEY,MAILGUN_DOMAIN, and optionallyMAILGUN_FROM(default<site name> <hailbox@<domain>>) andMAILGUN_API_BASE(https://api.eu.mailgun.netfor EU domains). The same setup sends operator sign-in links and visitor reply notifications. - Slack (optional). Pick one:
- Create a Slack app with the
chat:writebot scope, install it, invite the bot to your channel, and setSLACK_BOT_TOKENandSLACK_CHANNEL_ID. You get one thread per conversation. - Or set
SLACK_WEBHOOK_URLto an incoming webhook. Messages are posted flat, without threads.
- Create a Slack app with the
- Deploy, sign in at the inbox's URL, set your display name ("Edit name"), and point your
site's
<Hailbox apiUrl>at it.
WebSocket routes set maxDuration = 300 (the Hobby maximum). On Pro you can raise it to 800 in
apps/inbox/src/app/api/*/ws/route.ts so sockets reconnect less often.
- Visitors are anonymous. The first message creates a visitor and returns a random bearer
token, which the widget keeps in
localStorage(keyed by inbox URL) and sends on every request. The database stores only the token's SHA-256. There are no third-party cookies, so it works across origins and in browsers that block them. If you passuser={{ email }}, the stored conversation is tied to that user. When someone else signs in on the same browser, or you passuser={null}on sign-out, the widget starts fresh. - Writes go over REST and pushes over WebSockets. Sending, reading and updating the email
are plain
fetchcalls. The socket only delivers events. It authenticates with its first frame, so the token never appears in a URL. Whenever a socket (re)connects, the client fetches everything after the last message id it has. The database stays the source of truth, a dropped socket loses nothing, and the routine reconnect atmaxDurationis harmless. If sockets can't connect at all, the open panel falls back to polling. - Ordering. Posting a message locks the conversation row first, so message ids within a
conversation commit in order and
after=<id>is a safe cursor. - Fan-out. Vercel pins each socket to the instance that accepted it. Whoever handles a
write delivers the event to its own sockets and sends it with Postgres
NOTIFY. Every instance with open sockets holds one direct connection thatLISTENs and forwards events to its own sockets. A frame too big forNOTIFY(8000 bytes, which a long message can exceed) reaches other instances asready, which makes their clients re-fetch. If a listening connection drops, it reconnects and sendsreadytoo. That includes Neon scaling the database to zero; reconnecting wakes it, so with sockets open it rarely stays asleep for long, and you may prefer to turn scale-to-zero off. - Email notifications are scheduled with
waitUntilafter an operator replies. AfterNOTIFY_DELAY_SECONDS(default 60) the inbox checks whether the visitor has read the reply. If not, it emails all unread replies in one message. A burst of replies produces one email, and a conditional update stops two checks from both sending. The emails carry a signed one-click unsubscribe link (RFC 8058). If the conversation started on one ofHAILBOX_ALLOWED_ORIGINS, they also carry a "Continue the conversation" link back to that page, with?hailbox=openso the widget opens itself. - Slack. The first message of a conversation becomes the thread's parent message, with a link to the conversation in the inbox. Later messages from either side are posted as thread replies.
- Operator sign-in uses one-time magic links (15 minutes) that open a confirmation page
rather than signing in on GET, because mail scanners prefetch links. Sessions last 30 days and
are revoked when someone is removed from
OPERATOR_EMAILS.
- Replying from Slack or by email (Slack Events API / Mailgun inbound routes).
- Verified identity for logged-in users (an HMAC-signed user id from the host site), which would let a conversation follow someone across devices. Today a visitor is per-browser.
- Typing indicators, attachments, multiple concurrent conversations per visitor, and multi-site inboxes.
- Abuse controls beyond the built-in limits (20 messages a minute per visitor, 20 new visitors
an hour per IP). Consider Vercel Firewall rate limits on
/api/v1/*as well.