Full Case Study · 21 March 2026

Terminal
Chat

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.

Go · Golang Bubble Tea Ably SSE Linux Windows macOS Open Source
jishnu@kali — termchat
$ termchat -create 1337
✔ Connecting to Terminal Chat
✔ Loading recent messages
╌╌╌ you joined · history above ╌╌╌
14:31 Jishnu ↗: who's online?
14:32 ghost: i'm here. sending payload ⎘
14:32 ghost: https://pastebin.com/abc123
↳ pastebin.com · Encoded payload v3
👍 1 🔥 2
✎ ghost is typing…
Jishnu › _█
01 — Overview

What Is Terminal Chat?

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.

The Problem

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.

The Solution

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.

Who It's For

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.

The Result

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.

02 — Tech Stack

Everything Used to Build It

Every technology was chosen deliberately. The north star was zero-dependency installation for end users — which shaped every decision from language to networking protocol.

🐹
Go (Golang)
Core Language
Selected specifically because Go compiles to a single static binary with zero runtime dependencies. One `go build` produces an executable that runs on any machine without Go installed. Cross-compilation is built into the toolchain — a single command produces Linux, Windows, and macOS binaries from any platform. Goroutines handle multiple concurrent SSE streams without thread complexity.
v1.22stdlib onlycross-compile
🫧
Bubble Tea
TUI Framework
An Elm-inspired terminal UI framework implementing the Model-Update-View architecture. All application state lives in the Model struct. User input, async events, and timers communicate through typed tea.Msg channels. View() is a pure function — given the same model state it always renders the same output. This makes the entire UI deterministic and testable. Handles window resize events, keyboard input, mouse events, and async rendering correctly.
v0.25.0elm-architectureasync
💄
Lipgloss
Terminal Styling
CSS-like styling library for terminal output. Handles colors, padding, borders, width constraints, and text alignment through a composable style API. The entire interface uses the Catppuccin Mocha color palette applied through Lipgloss style objects. Critical insight during development: Lipgloss Width() counts ANSI escape bytes as characters, requiring manual padding calculation using utf8.RuneCountInString(stripAnsi(text)).
v0.9.1catppuccinansi
⚡
Ably
Real-time Infrastructure
Managed pub/sub platform providing real-time message delivery, channel history, and presence tracking. Accessed entirely via raw HTTP — no Ably SDK used — keeping the binary fully self-contained. The REST API handles publishing and history retrieval. SSE (Server-Sent Events) handles live message delivery. Ably's token authentication system enables the zero-key-setup user experience.
REST APISSEtoken authhistory
📡
SSE Protocol
Live Message Delivery
Server-Sent Events over plain HTTP for receiving real-time messages. Lighter than WebSockets — no handshake protocol, no upgrade headers, simpler client implementation. A persistent GET request to Ably's SSE endpoint streams newline-delimited events. Three separate SSE goroutines run concurrently — one for room messages, one for presence, one for DMs — each managed with a stop channel for clean shutdown.
persistent HTTPgoroutinesauto-reconnect
🚀
Render
Token Server Hosting
Free-tier Go web service hosting the token server that issues short-lived Ably tokens. This is the key architectural decision enabling zero-setup for end users — the Ably API key lives only as a Render environment variable, never in the binary or any repository. Users call GET /token, receive a 1-hour token, and connect. If the free tier sleeps (15-minute idle timeout), the 60-second client timeout accommodates cold start.
free tierenv varsGo HTTP
03 — Architecture

How It All Connects

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 Terminal
termchat binary
Linux / Windows / macOS
① GET /token
returns 1h token
🔐
Token Server
Go · Render
ABLY_KEY in env
② requestToken
Ably REST API
⚡
Ably Cloud
pub/sub · history
presence · SSE
③ SSE stream
live messages
💻
Other Users
any platform
same room number

Publish (sending a message)

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

Subscribe (receiving messages)

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

Presence (who is online)

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.

04 — Build Timeline

How It Was Built, Step by Step

The project evolved through clear phases — each one building on the last. Every major decision is documented here with the reasoning behind it.

Phase 1 · Planning & Language Selection
Choosing Go over Node.js, Python, and Rust
The original prototype was in Node.js — functional but requiring npm and a Node runtime on every machine. Evaluated four options:

Node.js — rejected. Requires npm install on every machine, global package manager setup.
Python — rejected. Requires pip, virtual environments, already installed but fragmented versions.
Rust — considered. Single binary, but steep learning curve and complex ecosystem for a TUI app.
Go — selected. Compiles to a single static binary. Standard library covers all HTTP/networking needs. Built-in cross-compilation. Goroutines are perfect for concurrent SSE streams.

The decision to avoid Ably's official SDK was also made here — using the SDK would add 15+ indirect dependencies, defeating the single-binary goal.
Go 1.22stdlibcross-compilation
Phase 2 · Core Messaging
Building the Ably Client from Raw HTTP
Built a minimal Ably client using only Go's `net/http` package. The REST client handles three operations: pub() — POST to `/channels/{name}/messages`, hist() — GET channel history, ping() — GET /time to verify connectivity.

The SSE subscriber goroutine uses a persistent GET with no timeout, reading lines from the response body scanner. Lines starting with `event:` set the event name, lines starting with `data:` set the payload, and blank lines dispatch the event. Auto-reconnects after a 1-second delay on any connection drop.

Three SSE goroutines run concurrently — room messages, presence events, DMs — each controlled by a shared stop channel closed on exit.
net/httpSSEgoroutineschannels
Phase 3 · Terminal UI
Building the Full TUI with Bubble Tea
Implemented the Model-Update-View pattern. The Model struct holds all application state — messages, users, room metadata, layout dimensions, input content. Update() handles every event type: keyboard input, window resize, async chat events, emoji picker navigation, typing indicators.

The responsive layout system was the most complex part. chatW and showSide values are computed once in relayout() and stored on the Model — View() reads them rather than recomputing, preventing width mismatches between render calls. The sidebar auto-hides below 90 terminal columns.

Viewport content is rebuilt with rebuild() on every resize to reflow message text to the new width. Header/status bar widths use manual ANSI-stripped measurement to avoid Lipgloss's ANSI-byte-counting bug.
Bubble Tea v0.25Lipgloss v0.9viewporttextarea
Phase 4 · Authentication
Zero-Setup Token Server Architecture
The original design asked users to create an Ably account and paste their API key. This was eliminated entirely.

The solution: A Go HTTP server deployed on Render that proxies Ably token requests. Users call GET /token → server calls Ably's `/keys/{keyName}/requestToken` with Basic auth → returns a 1-hour capability-limited token. The Ably API key lives only as a Render environment variable — never in any code or repository.

Critical discovery during integration: Ably SSE uses different auth parameters for tokens vs API keys. API keys use `?key=name:secret`, but tokens require `?accessToken=token`. Using the wrong parameter causes the SSE stream to fail silently — messages don't arrive but no error is thrown. Added detection: if the credential contains `:` it's a key, otherwise it's a token.
token serverRenderBearer authenv vars
Phase 5 · Features & Security
Private Rooms, Admin Controls, Polish
Added private room support with SHA-256 hashed passwords stored in Ably channel history as room metadata. Room creation publishes metadata once to a dedicated meta channel. On join, the metadata is fetched, password hash compared, and access granted or denied before any SSE connection is opened.

Admin system: room creator is flagged as admin in presence messages. Admin commands — `/kick`, `/ban`, `/admin` (transfer), `/close` — publish special event types that all clients subscribe to. Kicked users receive the kick event and exit after 800ms.

Message deduplication: history API and SSE stream both deliver recent messages, causing duplicates. Fixed by checking the message ID (Unix nanoseconds hex) before appending. Typing indicators use an 800ms debounce before broadcasting to prevent triggering on single keypresses.
SHA-256admin eventsdeduplicationdebounce
Phase 6 · Release
Cross-Platform Build & GitHub Release
Cross-compiled from Kali Linux using Go's built-in cross-compilation with `GOOS` and `GOARCH` environment variables. Linux and Windows binaries produced in a single command each. Both binaries stripped of debug symbols with `-ldflags="-s -w"` reducing size from 7.7MB to 6.2MB.

Published to GitHub Releases with both binaries attached. Users download and run with zero additional steps. The entire project — from idea to working cross-platform binary — was built and shipped in one focused session.
GOOS=windows-ldflags -s -wGitHub Releases
05 — Engineering Challenges

Problems That Required Real Thinking

These weren't bugs from typos — each one required understanding an underlying system deeply before the fix became obvious.

01

Silent SSE Authentication Failure

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.

Root Cause & Fix

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.

02

Layout Width Overflow at Certain Terminal Sizes

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.

Root Cause & Fix

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).

03

Every Message Appearing Twice

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.

Root Cause & Fix

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.

04

Layout Breaking on Window Resize

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.

Root Cause & Fix

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.

05

Token Server Cold Start Timeout

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.

Root Cause & Fix

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.

06

Typing Indicator Firing Immediately

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.

Root Cause & Fix

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.

06 — By the Numbers

Project Stats

Shipped in one focused session. Every number reflects real constraints and deliberate decisions.

6.2MB
Binary size stripped
0
User dependencies
1450+
Lines of Go code
3
Platforms supported
60s
Max cold start wait
Development time distribution across phases
TUI & layout system
30%
Core messaging & SSE
25%
Debugging & fixes
20%
Token server & auth
15%
Features & release
10%
07 — Features

Everything It Can Do

🔐
Private Rooms
Password-protected rooms with `termchat -s 1337`. Passwords are SHA-256 hashed before storage. Wrong password = access denied before any SSE connection is opened.
⚡
Real-time Messaging
Messages appear instantly on all connected terminals via SSE. No polling interval. Auto-reconnects on drop. Works Linux ↔ Windows ↔ macOS simultaneously.
✎
Typing Indicators
Live "ghost is typing…" shown to all room members. 800ms debounce before broadcasting. Auto-clears after 3.5 seconds of inactivity.
↳
Reply & Quote
`/reply` quotes last received message. `/reply username` targets a specific person. Quote renders inline above your message in the sender's unique color.
😄
Emoji Reactions
Tab opens a 60-emoji grid picker. Arrow keys navigate, Enter reacts. Reaction counts display as pills under each message. `/unreact` removes yours.
👑
Admin Controls
`/kick`, `/ban`, `/admin` transfer, `/close` room. Admin badge shown in header and sidebar. Transfer admin to any online user.
📜
Message History
Last 200 messages loaded on join. Clear separator marks where history ends. Mouse wheel scroll and pgup/pgdn navigate freely.
📡
Direct Messages
`/dm username message` sends privately to one user. Dedicated DM channel per user pair. DM badge shown inline in the message view.
🔗
Link Previews
URLs auto-detected. Page title fetched in background goroutine. Preview renders below the message with URL and title — non-blocking.
📊
Room Stats
`/stats` shows: room ID, online count, total messages, messages you sent, messages received, session duration.
⎘
Copy to Clipboard
`/copy` copies the last received message. Cross-platform clipboard support via atotto/clipboard — works natively on Linux, Windows, macOS without external tools.
📐
Responsive Layout
Sidebar shows at 90+ columns, hides on narrow terminals. Entire layout recalculates on every resize event. Message content reflows to new width automatically.
08 — Quick Install

Get Running in 30 Seconds

No package manager. No dependencies. Download the binary for your OS and run it. That's it.

🐧
Linux
Kali · Ubuntu · Debian · Arch · Fedora
Step 1 — Install Go
$ sudo apt install golang-go -y
# or download from https://go.dev/dl/
Step 2 — Clone the repo
$ git clone https://github.com/Jishnuec/terminalchat.git
$ cd terminalchat
Step 3 — Build
$ go mod tidy
$ go build -ldflags="-s -w" -o termchat ./main.go
Step 4 — Run
$ ./termchat 1337
$ ./termchat -create 1337
Optional — Install globally
$ sudo mv termchat /usr/local/bin/termchat
After global install: termchat 1337 works from anywhere.
🪟
Windows
Windows 10 · Windows 11 · PowerShell
Step 1 — Install Go
> # Download Go installer from
https://go.dev/dl/
# Run the .msi and restart PowerShell
Step 2 — Clone the repo
> git clone https://github.com/Jishnuec/terminalchat.git
> cd terminalchat
Step 3 — Build
> go build -o termchat.exe main.go
Step 4 — Run
> .\termchat.exe 1337
> .\termchat.exe -create 1337
Requires Go from https://go.dev/dl/ — install once, build anywhere.
🍎
macOS
macOS 12+ · Intel · Apple Silicon
Step 1 — Install Go
$ brew install go
# or download .pkg from https://go.dev/dl/
Step 2 — Clone the repo
$ git clone https://github.com/Jishnuec/terminalchat.git
$ cd terminalchat
Step 3 — Build
$ go mod tidy
$ go build -ldflags="-s -w" -o termchat ./main.go
Step 4 — Run
$ ./termchat 1337
$ ./termchat -create 1337
Requires Go — get free at https://go.dev/dl/ or via Homebrew.
Build from source · requires Go 1.22+
$ git clone https://github.com/Jishnuec/terminalchat.git && cd terminalchat && go mod tidy && go build -ldflags="-s -w" -o termchat ./main.go && ./termchat 1337
Cross-compile from Linux · produces all platform binaries
$ GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o termchat-windows.exe ./main.go
$ GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o termchat-mac ./main.go
$ GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o termchat-linux ./main.go
Usage Reference
Commands
$ termchat 1337 # join open room
$ termchat -create 1337 # create room
$ termchat -s 1337 # private room
$ termchat -create 1337 --limit 5
In-Chat Commands
/reply        quote last message
/dm <user> <msg>  private message
/react <emoji>   react to last msg
/copy          copy to clipboard
/stats         room + session stats
/kick /ban /admin /close
09 — Links

Find It Online