An encrypted terminal chat app you run yourself. One person hosts, everyone else joins from their own terminal with a passphrase. All messages on the wire are encrypted. The host chooses the name, logo, and colors — every joiner's terminal picks that up automatically.
Single file, no accounts, no cloud, no logs on disk.
- Mac (M1/M2/M3 or newer) →
shellchat-VERSION-darwin-arm64.tar.gz - Mac (Intel, older than 2020) →
shellchat-VERSION-darwin-amd64.tar.gz - Windows →
shellchat-VERSION-windows-amd64.zip - Linux →
shellchat-VERSION-linux-amd64.tar.gz
Double-click the downloaded file. You'll get a folder with three items inside:
shellchat(orshellchat.exeon Windows) — the program itselfinstall.sh(orinstall.ps1on Windows) — a helper that puts the program somewhere your terminal can find itREADME.md— this file
Mac / Linux — open the Terminal app, drag the install.sh file into it, and press Enter.
Windows — double-click install.bat. A window opens, tells you what it did, and waits for you to press Enter before closing. (If it flashes and closes instantly, you clicked install.ps1 by mistake — use install.bat.)
That's it. Open a new terminal window afterwards — the shellchat command only appears on PATH in newly-opened terminals.
Type:
shellchat serve
A setup screen appears. It asks you a few questions:
- Server name — whatever you want. Shows up at the top of everyone's chat.
- Message of the day — a one-line greeting people see when they join.
- Logo — leave blank to use the default logo, or paste a path to your own PNG image.
- Port — a number between 1024 and 65535.
6667is a fine choice. - Passphrase — this is what protects your chat. Share it by voice or in-person with the people you want to let in. Never text or email it.
- Everything else has a sensible default; press Enter to keep it.
Use ← → to pick options on the theme and border screens, Shift+Tab to go back a step, Esc to cancel.
After the review screen, press Enter. Your server is now running. Leave that terminal window open.
How do others find your server?
- Same wi-fi — everyone types
shellchat join YOUR-IP:PORT, using the IP shown when you connect to your network (e.g.192.168.1.42:6667). - Anywhere in the world — you'll need to either forward the port on your router (ask your provider) or use a tunnel service like Tailscale or ngrok. This is the one bit that requires a bit of research; the app itself will just work.
They'll give you three things: the address (something.example.com:6667 or an IP), the passphrase, and (optionally) a preferred username.
Type:
shellchat join ADDRESS
It'll ask for your username and passphrase, then drop you into the chat.
Run shellchat setup to re-open the wizard on an existing install. Every field is pre-filled with your current setting — press Enter to keep it, or type a new value. When you finish, config.toml is overwritten and the next shellchat serve uses the new settings.
- Type a message and press Enter to send.
- Type
:followed by a name to insert an emoji (:tada:→ 🎉). Tab picks the highlighted one. - PgUp / PgDn scrolls back through the conversation.
- Ctrl+C or Esc leaves the chat.
That's the whole app. Have fun.
Short answer. Anyone without the passphrase cannot read your messages, and nothing gets written to disk. This is enough for a private group chat, a game night, a workshop, or any conversation that just shouldn't be visible to random observers.
Longer answer. Read the Security model section further down. If the honest limitations there matter for your situation (e.g. you don't trust the host, or you need forward secrecy), use Signal or Matrix instead — those are the right tools for that job. shellchat is for confidential-against-outsiders group chat, not for hiding traffic from the person hosting it.
Mac / Linux — delete the file the installer created (it will tell you the path — usually /usr/local/bin/shellchat or ~/.local/bin/shellchat).
Windows — delete the folder %LOCALAPPDATA%\Programs\shellchat.
Nothing else is touched.
For engineers, contributors, and hosts who want to know exactly what they're running.
Single Go binary, shellchat. Two subcommands:
shellchat serve # run listener + fanout hub (setup wizard on first run)
shellchat setup # re-run wizard on an existing install, overwrite config.toml
shellchat join # dial a running server, drop into TUI chat
Bubble Tea + Lip Gloss TUI, standard-library TCP, Argon2id KDF, XChaCha20-Poly1305 AEAD, TOML config, embedded PNG default logo rendered as ▀/▄ half-block Unicode art. No CGO — binaries are static.
bash scripts/build.sh # linux/darwin/windows × amd64/arm64
VERSION=v0.1.0 bash scripts/build.sh
Each archive under dist/ contains the binary, the platform-appropriate installer, and this README.
cmd/shellchat/main.go CLI dispatch
internal/config TOML load/save
internal/crypto Argon2id + XChaCha20-Poly1305 helpers
internal/protocol length-prefixed framing + JSON message shapes
internal/pathx cross-OS path expansion (~ , $VAR, %VAR%, quoted)
internal/logo PNG → half-block art + embedded default logo
internal/banner figlet or file-mode ASCII banner
internal/theme preset + custom color resolution + hash username colors
internal/emoji shortcode expansion + single-glyph filter + completion
internal/server TCP listener + auth + broadcast hub
internal/client dial + handshake + send/recv
internal/tui Bubble Tea chat model + setup wizard
Frames are length-prefixed: 4-byte big-endian uint32 length + payload. Every payload after the handshake is an XChaCha20-Poly1305 sealed blob (24-byte random nonce prepended to ciphertext).
Handshake. The server sends a plaintext JSON greeting:
{
"server_name": "…",
"motd": "…",
"theme": { ... },
"banner": "…",
"logo": "…", // pre-rendered ANSI half-block art
"argon2_salt": "base64",
"challenge": "base64", // 32 random bytes
"max_message_length": 2000
}The client derives the key k = Argon2id(passphrase, salt, t=3, m=64MB, p=4, keyLen=32) and returns a sealed AuthPayload{ challenge, username }. The server opens it with its own derived key; if the AEAD tag verifies and the returned challenge matches, the peer is registered. Otherwise the connection closes after a short delay to blunt guessing.
Chat frames. Every post-handshake frame is a sealed JSON Message:
{ "type": "msg|join|leave|system", "user": "ztf", "body": "hey :tada:", "ts": 1737600000 }The server treats client-sent msg frames as ciphertext to fanout, but re-stamps user with the peer's authenticated name and rejects anything longer than max_message_length. join/leave/system frames are always server-generated and sealed with the same shared key so every client renders them identically.
Single config.toml next to the binary. Written by the wizard on first run; hand-editable afterwards.
[server]
name = "shellchat"
motd = "welcome to the wire"
port = 6667
passphrase = "correct-horse-battery"
max_message_length = 2000
scrollback = 500
[display]
timestamps = true
clock = "24h"
join_leave_msgs = true
username_colors = "hash" # "hash" | "fixed"
prompt_glyph = "❯"
[theme]
preset = "meatspace" # "meatspace" | "vanadinite" | "custom"
border_style = "rounded" # rounded | thick | double | normal | hidden
# when preset = "custom", these override:
bg = "#0a0a0f"
fg = "#663399"
accent = "#39ff14"
username = "#00f5ff"
system_msg = "#cc99ff"
timestamp = "#ffcc00"
error = "#ff2d78"
success = "#39ff14"
border = "#663399"
[banner]
mode = "figlet" # "figlet" | "file"
file = "banner.txt" # used when mode = "file"
logo_file = "" # absolute path to a PNG; blank = embedded default
logo_width = 20 # logo width in terminal cells (6..200); 20 is compact, 40 is large
[emoji]
custom_file = "emoji.toml" # optional shortcode → glyph aliasesPath fields (banner.file, banner.logo_file, emoji.custom_file) are run through pathx.Expand, which understands ~, ~/…, $VAR, ${VAR}, %VAR%, quoted paste, and forward/back slashes — so the same config authored on any OS resolves correctly.
Two shipped presets: meatspace (rebeccapurple + neon-green cyberpunk) and vanadinite (oxidized-red mineral palette). Set preset = "custom" to use the explicit hex fields. username_colors = "hash" hashes each username to a stable color from a fixed palette (classic IRC look). The resolved theme is pushed in the handshake so every joiner sees the host's branding, not their own defaults.
enescakir/emoji provides the base shortcode map. It's filtered down to single-glyph outputs — no ZWJ family sequences or skin-tone joins, which would break terminal column math. emoji.toml next to the binary adds custom aliases (shrug = "¯\\_(ツ)_/¯", ztf = "", etc.), and those pass the same single-glyph filter. : in the input triggers Bubble list completion; Tab inserts the highlighted shortcode.
- The passphrase-derived key is a shared symmetric key. It protects messages against network eavesdroppers and anyone without the passphrase.
- It is not per-peer end-to-end encryption: the host operator holds the same key and could decrypt traffic if they modified the relay code. The stock server never opens
msgpayloads — it just forwards ciphertext — but the threat model is "confidential against outsiders," not "server can't read." - There is no forward secrecy. If the passphrase leaks, any previously captured ciphertext becomes readable.
- Usernames are enforced server-side after authentication — a client cannot spoof another user's name in
msgframes. - Nothing is written to disk. Scrollback is in-memory only; the config file stores the passphrase in cleartext (needed to derive the key at boot).
For real per-peer E2E + FS you want Signal, Matrix, or a Noise-protocol build. A Noise upgrade path is the obvious v2 direction for this repo.
go test ./... -count=1 -timeout 30s
go run ./cmd/shellchat serve --config /tmp/dev.toml
Integration tests in internal/server/server_test.go cover the roundtrip (two joiners, one message, decrypted on the other side) and the bad-passphrase rejection path.
- No true per-peer E2E / forward secrecy (see security section).
- No inline images (Kitty/Sixel).
- No multi-room / channels — one room per server.
- No message persistence.
- No file transfer, no DMs.
