An anonymous, cross-platform real-time chat application that lives entirely inside the terminal. No accounts. No logs. No browser. One binary file, one number, instant secure communication.
A complete real-time communication system built entirely for the terminal. Every design decision was made to eliminate friction — no accounts, no configuration, no dependencies. Just download a binary and start talking.
Every modern chat platform requires an account, a phone number, or permanently stores your messages in a company's database. For developers and security professionals sharing sensitive text — API keys, configs, code snippets, private data — this creates unnecessary risk and friction. Clipboard tools break across sessions. Pastebin leaks. Slack logs everything.
Terminal Chat creates ephemeral numbered chatrooms accessible from any terminal on Earth. Type a number, get a room. Messages are real-time. No personal information is ever collected. The server never stores plaintext messages permanently — only Ably's short-lived channel history which expires automatically.
Security researchers sharing CTF payloads. Dev teams needing a quick throwaway channel. Penetration testers coordinating during an engagement. Anyone who wants to send text to another person without creating an account or leaving a permanent trace. Works on Kali Linux, Ubuntu, Windows 10/11, macOS.
A production-ready CLI application with real-time messaging, typing indicators, emoji reactions, private password-protected rooms, admin controls, DMs, message history and link previews — all in a 6.2MB single binary that requires zero installation from the end user.
Every technology was chosen deliberately. The north star was zero-dependency installation for end users — which shaped every decision from language to networking protocol.
Three-tier architecture with zero infrastructure managed by the user. The token server is the only custom backend — everything else is a managed service.
User presses Enter → termchat serialises ChatMsg to JSON with unique ID (Unix nanoseconds hex) → HTTP POST to Ably REST API `/channels/{name}/messages` with Bearer token → Ably distributes to all SSE subscribers on that channel → remote clients receive via persistent SSE stream → deduplication check on message ID → Bubble Tea evChat message → View() rerenders
On startup, three goroutines open persistent GET connections to Ably SSE endpoint using `?accessToken=token` parameter → each goroutine parses newline-delimited event stream → sends typed Go structs to Bubble Tea program via prog.Send() → Update() handles each event type → state mutation → View() rerenders the full terminal UI every frame
Custom presence system built on Ably channel history. Every client publishes heartbeat messages every 20 seconds. A separate goroutine runs an eviction loop — users whose last heartbeat timestamp is older than 90 seconds are removed from the local presence tracker and a "disconnected" system event is shown. Join/leave/typing actions also publish to the presence channel.
The project evolved through clear phases — each one building on the last. Every major decision is documented here with the reasoning behind it.
These weren't bugs from typos — each one required understanding an underlying system deeply before the fix became obvious.
After implementing the token server, live messages completely stopped working. REST API calls (publish, history) worked perfectly — tokens were valid — but the SSE stream received nothing. No error, no rejection message. The stream opened but was empty.
Ably's SSE endpoint uses different auth parameters depending on credential type. API keys use ?key=name:secret. Short-lived tokens require ?accessToken=token. The code was using key= even for tokens — Ably accepted the connection silently but delivered nothing. Fixed by detecting the credential type: if it contains : it's a key, otherwise a token, and the URL parameter is set accordingly.
At specific terminal widths, the header background bar would either overflow past the right edge or leave a visible gap. This happened inconsistently — some widths looked perfect, others broke completely. No obvious pattern.
Lipgloss's Width(n) function counts ANSI escape bytes as visible characters when measuring how much padding to add. A header with colored text has 40+ invisible ANSI bytes, making Lipgloss think the string is much longer than it appears. Fixed by measuring the visible length with utf8.RuneCountInString(stripAnsi(text)) and manually padding with strings.Repeat(" ", chatW-visLen).
After adding message history loading on join, every message appeared twice in the chat — once from history and once from the live SSE stream. Users joining a busy room saw dozens of duplicates.
The Ably history API and the SSE stream both deliver recent messages. When joining, history loads the last 200 messages. The SSE stream — which was already open — also delivers those same messages as they replay. Fixed with a deduplication check: every ChatMsg has a unique ID (Unix nanoseconds as hex string). Before appending an incoming message, the existing list is scanned for matching IDs. Duplicates are silently dropped.
When resizing the terminal, the header, messages, and input would misalign. Sometimes the viewport content would stay at the old width — showing 80-column-wrapped text in a 60-column window. The sidebar would flicker in and out incorrectly.
Two separate issues: (1) relayout() updated viewport dimensions but didn't call rebuild(), so message content wasn't reflowed to the new width. (2) View() recalculated chatW independently from relayout(), producing slightly different values due to ANSI byte counting. Fixed by storing chatW and showSide on the Model struct — computed once in relayout(), read by View() — and calling rebuild() on every WindowSizeMsg.
Users on the first connection after a period of inactivity received "cannot connect" errors immediately. The token server was working fine — it just took 30-50 seconds to wake up from Render's free-tier sleep state.
The HTTP client timeout for the token fetch was 10 seconds — far too short for a cold start. Render's free tier sleeps web services after 15 minutes of no traffic, and the first request triggers a container restart that takes 30-50 seconds. Fixed by increasing the token fetch timeout to 60 seconds and updating the connection spinner message to inform users that the first connection may take up to 30 seconds.
The typing indicator was appearing on other users' screens as soon as the local user pressed a single key — including backspace, arrow keys, and modifier keys. This was spammy and made the presence channel noisy.
The original implementation sent a "typing" presence message on the first keypress immediately. Fixed with an 800ms debounce timer — the typing indicator only broadcasts after 800ms of continuous typing activity. The timer is reset on every keypress. A separate 3.5-second timer automatically sends "stoptyping" after the user stops, regardless of whether they sent a message.
Shipped in one focused session. Every number reflects real constraints and deliberate decisions.
No package manager. No dependencies. Download the binary for your OS and run it. That's it.
termchat 1337 works from anywhere.https://go.dev/dl/ — install once, build anywhere.https://go.dev/dl/ or via Homebrew.