Skip to content

A Social Feed You SSH Into — One Domain Core, Two Front Doors

A friend, half-joking: “your feed’s just a backend with a REST API… could I ssh into it and read it in my terminal?” Yes — and you can do more than read: like, comment, reply, follow, delete, all over SSH, with your SSH key as the login. But here’s the part worth writing about: building the terminal UI was the easy bit. The interesting bit was making sure it reused all of the backend — zero duplicated logic — which forced a refactor I should have done anyway.

Liking, commenting, replying, and posting — all over SSH, identified by my public key, on a feed running on a 2008 PC.

(This rides on top of the whole news-feed — same Postgres, same everything. It adds a front door, not a feature.)

The Go ecosystem has a lovely set of libraries from Charm: wish (an SSH server you write in Go), Bubble Tea (a terminal-UI framework), Lip Gloss (styling). Together: someone runs ssh feed.example.com and a TUI of the feed appears in their terminal. wish even hands you the client’s public key on connect — so the SSH key can be the login.

So the TUI needs to read posts and write likes/comments/follows/deletes. The lazy way: have the TUI talk straight to Postgres and run the inserts itself.

That’s a trap, and naming why is the whole point of this post. My write paths aren’t plain inserts — each carries real machinery:

If the TUI re-implemented those, I’d have two divergent copies of my logic — and the TUI’s copy would quietly skip the outbox (no fan-out), skip the counter, skip the ownership check. That bug isn’t hypothetical; it’s guaranteed the moment you copy-paste domain logic into a second caller.

So the real question was never “how do I write CRUD in a TUI.” It was: how does a second interface reuse the logic the first one already has?

The answer is ports and adapters (hexagonal architecture). Pull the business logic out of the HTTP handlers into a package of plain functions — internal/service — and let both the HTTP handlers and the SSH TUI call them.

A service function looks like this:

func Like(ctx context.Context, q sqlc.Querier, counter *cache.Client, userID, postID int64) (LikeResult, error)

No http.ResponseWriter, no ssh.Session — just domain inputs and a domain result (plus a sentinel error the caller translates). The HTTP handler shrinks to a translator:

res, err := service.Like(r.Context(), queries, counter, userID, postID)
if errors.Is(err, service.ErrPostNotFound) { errorJSON(w, 404, "post not found"); return }
writeJSON(w, LikeResponse{Liked: res.Liked, LikeCount: res.Count})

…and the TUI calls the exact same function — it just renders the result as a line of green text instead of JSON.

Domain vs. delivery — the line that matters

Section titled “Domain vs. delivery — the line that matters”

Doing this forces you to sort every feature into one of two buckets, and that sorting is the senior move:

FeatureShared (domain)?In the TUI?
fan-out, outbox, embeddingsyes — via the shared CreatePost
hot-counter likesyes
comment threading, ownership checksyes
idempotency keys❌ delivery (HTTP)no — and correctly
rate limiting❌ delivery (HTTP)no
JWT auth❌ delivery (HTTP)no — the SSH key replaces it

The ❌ rows aren’t losses. Idempotency keys guard against a network client retrying a request whose HTTP response was lost — a TUI keypress isn’t that, so bolting it on would be wrong. Rate limiting is public-endpoint abuse protection. JWT is HTTP’s login; the TUI’s login is the key. Each concern lives with its adapter; the domain stays clean.

The one subtlety: who owns the transaction

Section titled “The one subtlety: who owns the transaction”

Creating a post writes the post and an outbox event, atomically. But the HTTP handler also wants to write an idempotency-key row in that same transaction. If service.CreatePost opened and committed its own transaction, the handler couldn’t slip its row in.

So the service takes a sqlc.Querier and does not own the transaction — the caller does:

// runs on whatever the caller passes: the pool, OR a transaction
func CreatePost(ctx context.Context, q sqlc.Querier, authorID int64, content string) (sqlc.Post, error)

The HTTP handler does pool.BeginCreatePost(tx, …) → add idempotency row → commit. The TUI does pool.BeginCreatePost(tx, …)commit. Same domain code, each caller wrapping its own concerns. That “caller owns the boundary” choice is what makes the reuse actually work.

With the logic shared, the front door is tiny. wish accepts every key, and we identify (not gate) the user by it:

wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool { return true }),
// ...then, per connection:
authKey := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(s.PublicKey())))
if u, err := queries.GetUserBySSHKey(ctx, authKey); err == nil {
userID, username = u.ID, u.Username // recognized → you can act
} // unknown key → read-only guest

A small ssh_keys table maps a public key to a user. No password, no token — register your public key once, and from then on ssh is your authenticated session. The interview line writes itself: “same backend, two front doors — REST and SSH — and the SSH key replaces the JWT.”

Bubble Tea uses the Elm Architecture: a Model (state), Update(msg, model) → model (events in, new state out), and View(model) → string (render). The runtime loops: draw → wait for a key → update → draw.

To support typing and a comment thread, the model became a small state machine with a mode — list / input / comments — and Update routes each keypress by mode. The actions are one-liners onto the service:

case "l": m = m.toggleLike(1) // → service.Like
case "d": m = m.deleteSelected() // → service.DeletePost (ownership enforced)
case "f": m = m.followAuthor() // → service.FollowUser
case "n": // open a text box → service.CreatePost inside a transaction

And the bit my friend was smuggest about — replying to a comment. In the thread view, r grabs the selected comment’s id and passes it as the new comment’s parent:

parentID := m.comments[m.commentCursor].ID
service.AddComment(ctx, q, postID, userID, &parentID, content) // &parentID ⇒ a reply

Reload the thread and the recursive CTE returns it one depth deeper — so it renders indented under its parent: a real nested thread, in a terminal you SSH’d into.

The part I enjoy saying out loud: none of this is on a cloud VM. It runs on a 2008 Core-2-Duo desktop — my homelab’s “Old PC” — as a k3s cluster, installed from a Helm chart.

One Go image, built once, runs as four roles, each chosen at container start by which binary its Deployment launches:

rolejob
apithe HTTP/JSON server (2 replicas)
sshthe wish + Bubble Tea front door from this post
workerconsumes post.created, fans out, embeds via Ollama
relaydrains the transactional outbox into RabbitMQ

They sit in front of the same Postgres (pgvector), RabbitMQ, and Redis. kubectl get pods lists the whole thing — eight pods — humming on a machine old enough to vote. Behind every write runs the async chain post → outbox → relay → RabbitMQ → worker → Ollama → pgvector — and even the embeddings run on that same box: Ollama on the Core-2-Duo’s CPU (no AVX; ~14 s per embed — glacial, but it’s async, so nothing waits on it). No second computer is in the loop; the whole system, LLM included, is self-contained on one 2008 machine. (I reach it from my laptop over Tailscale — but the Old PC does all the actual work.)

So the real headline isn’t “a feed you can SSH into.” It’s: one domain core, two front doors, fronting a genuinely distributed system — multiple services, async messaging, a vector store — deployed on hardware from 2008.

It’s all open-source. Because it lives on a home machine over a private Tailscale network — no public IP, and I’m not about to open an SSH server to the whole internet from my living room — the honest way to play with it is to run your own. It comes up identically anywhere:

Terminal window
git clone https://github.com/omkar619-dev/news-feed-go && cd news-feed-go
docker compose up -d # Postgres (pgvector) + RabbitMQ + Redis
# load the schema, start Ollama, run the four binaries — full steps in the README
ssh -p 23234 localhost # register your key once, and the feed is yours

The README has the copy-paste version.

  • The service calls run synchronously inside Update, briefly blocking the UI. For single fast queries it’s invisible; the idiomatic Bubble Tea fix is to return a tea.Cmd that does the work off-loop and sends a result message back. Known shortcut.
  • The SSH server is trusted to mint identity from keys — fine for a self-hosted, single-operator app; a multi-tenant version would need a proper key-registration flow.
  • The thread view shows user #<id>, not usernames — the tree query returns author ids; joining names is a small follow-up.
  • I lost ten minutes to a great bug: the mouse wheel sends an escape sequence whose leading Esc byte my code read as the “quit” key — so scrolling closed the SSH session. Capturing mouse events properly (and not quitting on a bare Esc) fixed it.

The novelty — a feed you SSH into — was a weekend’s fun. The thing worth keeping is the refactor it forced: if adding a second interface means copy-pasting your business logic, your business logic is in the wrong place. Move it to a domain core, make the interfaces thin translators, and a second front door costs almost nothing — and it sharpens your design, because you have to decide, feature by feature, what’s domain and what’s merely delivery.

It’s all in the repointernal/service is the core, cmd/ssh is the terminal adapter, and the HTTP handlers are now thin translators over the same functions.