Vibe code your own Slack

Vibe code your own Slack as channels and messages. Presence, search, unread cursors, and fanout to a million clients are the other product. Here is the split.

  • teardown
  • vibe-coding
  • scope

You can vibe code your own Slack. Start with a handful of channels and the messages in them. The catalog calls Slack three topic rooms, not 3,000 integrations.

You pay $15–$18 a person a month. The card says that price scales with the team. The verdict is “Only for a focused team”.

Earlier today I wrote about building a Carrd alternative. That was a regular backlog post. This one is a teardown: what is public about the architecture, which half you can vibe-code, and which half you should leave alone.

Vibe code your own Slack as a campfire

The Slack page splits the work into three rows.

The first row is 4 days. Channels, messages, threads. That is a room, a list of posts, and replies hanging off a parent. You can vibe-code this.

The second row is 2 weeks and up. Reactions, file uploads, search, unread markers. Still a campfire. More screens, a cursor per person, an index.

The third row is 3 months and up. Voice calls, an app platform, mobile apps. Skip that row.

If you tell an agent to “clone Slack”, it aims at the third row. The useful v1 is the first one.

Here is the shape of the data. Nothing else has to exist at 4 days.

const message = {
  channelId: 'C-eng',
  ts: '1792427506.000184',
  user: 'U-flavio',
  text: 'The booking page is live at https://flaviocopes.com/slot',
  threadTs: null,
}

A workspace, a few channels, membership, messages with stable ids. Reactions, files, and search can wait.

The audience line on the page is a small internal team that wants searchable asynchronous conversation. If that is you, and the group will move, a self-hosted campfire is enough. If the company already lives in Slack, keep paying. Chat is their source of truth, the same way Linear is for issues. How Linear works is that split.

I already used Slack as the “do not start here” example in what to vibe code first.

How does Slack work

Slack publishes a Web API at https://slack.com/api. I am summarizing their docs and two engineering posts, not reconstructing their database.

You post with POST /chat.postMessage. You send a channel and some text. A reply sets thread_ts to the parent’s id, and they tell you to use the parent, never another reply. That is in the postMessage reference.

You read history with GET /conversations.history. Slack’s retrieving messages guide is explicit: ts looks like a Unix timestamp with milliseconds, and it is the message id, unique inside one channel. Store channel plus ts.

A vibe-coded campfire needs none of those endpoints. Copy the product rule: a channel-scoped id, a parent pointer for a thread, membership checked on the server.

Their error catalog for chat.postMessage mentions client_msg_id when a duplicate cannot be found. That is a retry key. I have not seen it as a public write field in the current argument list, so treat “you send one on every post” as an educated guess. I would still ship a unique constraint on channel plus a client key.

The mini-prompt on the page is “Persist first, broadcast second”. The database assigns order.

async function postMessage(db, { channelId, userId, text, clientKey, threadTs }) {
  const existing = await db.findByKey(channelId, clientKey)
  if (existing) return existing

  const message = await db.insertMessage({
    channelId,
    userId,
    text,
    threadTs: threadTs ?? null,
    clientKey,
  })

  await db.broadcast(channelId, message)
  return message
}

That is a D1 insert plus a Durable Object room, not Slack’s protocol. Kill the socket and retry, and you still have one row.

Message fanout is the other product

Sameera Thangudu published Real-time Messaging on April 11, 2023. I am summarizing that post, not drawing their current network.

Core services are Java. Channel Servers hold some channel history in memory. Each channel id hashes onto one Channel Server. At peak they said about 16 million channels per host. “Channel” there is broader than #eng. User, team, file, huddle, a regular Slack channel all land on the same ring.

Gateway Servers hold WebSocket subscriptions, in multiple regions, so a laptop talks to a nearby edge. Admin Servers sit between the Webapp and the Channel Servers and are stateless. Presence Servers are a separate in-memory cluster. Users hash onto them.

A client does not open a Channel Server. On boot it asks the Webapp, a Hacklang service, for a token and connection info. Then it opens a WebSocket to the nearest gateway. Envoy terminates TLS. The gateway loads the user’s channels and subscribes to the Channel Servers that own those ids.

Sending is a different path from receiving. The client posts to the Webapp API. Webapp hands the message to an Admin Server, which hashes the channel id, finds the Channel Server, and that host fans the message out to every Gateway Server in the world that is subscribed. Each gateway writes it to every connected client in that channel.

They titled that section “Send a message to a million clients in real time”. Delivery across regions is in 500ms in that write-up. Tens of millions of channels per host, tens of millions of connected clients. Those are 2023 numbers.

Typing is not persisted. The client sends it on the WebSocket, the gateway hashes to a Channel Server, and fanout is the same. Miss a typing ping and there is nothing to replay.

CLIENT POSTS Webapp API, then persist ADMIN SERVER Hash the channel id CHANNEL SERVER One host, millions of ids GATEWAY SERVERS Every subscribed region gets one copy CONNECTED CLIENTS Each gateway writes locally
The 2023 path: persist through Webapp, then fan out from the hashed Channel Server.

A 4-day v1 does not need a consistent-hash ring or a gateway in every region. One Durable Object per channel is enough. Members of #eng connect to that object. Replay is SELECT after the last ts you hold.

Treat “Slack still runs this exact Java topology” as an educated guess past 2023. The product rule still matters. Order is assigned at persist time. Catch-up is a query.

Presence is a second product

GET /users.getPresence returns active or away. If you ask about yourself, Slack also sends online, auto_away, manual_away, connection_count, and last_activity. Their docs say auto_away is true when servers have not seen activity in the last 10 minutes.

The 2023 post puts that work on Presence Servers. Clients query through the WebSocket, with the gateway as a proxy, and receive presence only for users visible on screen.

Bing Wei’s 2017 post Flannel (updated June 26, 2020) is the older half. The original rtm.start payload shipped every user, channel, and membership. Flannel is an application-level cache at the edge, so clients bootstrap thin and query the rest on demand. They moved presence onto pub/sub and said presence events to clients dropped by a factor of 5. Those numbers are from 2017.

The 4-day row has no green dots. The catalog already swaps them for an email digest.

Unread cursors only move forward

Unread is a cursor per member per channel.

Slack’s conversation object has last_read, unread_count, and unread_count_display. last_read is the ts of the last message the calling user has read. unread_count_display skips join and leave noise. Some of those fields show up on DMs in conversations.info. I will not claim every public channel returns the same counters. Copy the cursor.

You move it with POST /conversations.mark. Channel plus a message ts. Slack’s mark docs say the mark is saved, then broadcast on the message server to every open connection for that user. They also tell you not to hammer it. Set a timer. Five seconds is their starting point, so scrollback in a busy channel is one write.

The mini-prompt is “The unread line that never moves backward”. A lagging tab must not resurrect messages you already read.

function advanceCursor(currentTs, incomingTs) {
  return parseFloat(incomingTs) > parseFloat(currentTs) ? incomingTs : currentTs
}

advanceCursor('1792427506.000184', '1792427506.000120')
//'1792427506.000184'

Threads need their own cursor, or a busy thread pins the whole channel unread. That is the 2-week row.

Reducing Slack’s memory footprint is the desktop version. Backgrounded teams unload the full client. A tiny one polls has_unreads and mention_count from memcache. A four-person campfire can count rows after the cursor.

Search cannot leak a private channel

Search sits on the 2-week row.

Slack’s 2017 post Search at Slack (updated June 26, 2020) said Solr powered search then. Recent sort is reverse chronological. Relevant sort used the Lucene score, then a re-ranker over the searcher’s work graph. After adding that graph they reported a 9% increase in searches that got a click, and a 27% increase in clicks at position 1.

That write-up is nine years old. Treat “Solr still sits under the search box” as an educated guess. The constraint has not aged. Each user sees a different set of documents, and membership changes every afternoon.

The mini-prompt is “Search that can’t leak the leadership channel”. Index text if you want. Check permission at query time against the searcher’s current memberships. A snapshot of who-can-see-what in the index is stale the moment someone leaves #leadership. Tombstones never match. An in:#leadership operator returns nothing.

A 4-day v1 can skip search. A LIKE filter on one channel is fine if you are five people.

A Slack alternative for a focused team

4-DAY V1 Channels and membership Messages with a ts Threads off a parent SKIP THIS HALF Presence and green dots Company search and unreads Fanout to a million clients
Ship the campfire. Leave presence, search, and million-client fanout on the skip row.

I would rebuild Slack for a team of four who currently paste into a group DM they do not own. Three channels, messages, threads, private-channel checks on the server, and a uniqueness constraint so a retry is not a double post.

I would host it next to the product, on Astro, D1, and a Durable Object per channel, because that is the stack on the card. Use Rails if that is your weekday language. Add unread cursors after the first week of real use. I would not build presence, voice, bots, guests, or a mobile app.

Edits keep the same ts. Deletes leave a tombstone so a thread does not lose its parent. That mini-prompt is “Deleted messages leave polite corpses”. Do it before you let anyone hit delete.

If the rent is a member community with spaces and a weekly digest, that is Circle, $99–$219 a month, four days for spaces, posts, members, and digests. If twelve coworkers already treat Slack as the record, keep paying. Notion is the same test with pages.

Start with the first row

Go to Build your own Slack. Copy the AHA prompt, or the stack you already write.

Tell the agent the first estimate only: channels, messages, threads. Four days.

If the company already lives in Slack’s search box, keep paying.