Vibecode Twist
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Twist, host asynchronous team threads organized by channel and topic. The hard boundary is cross-platform apps, notifications, email integration, search, and doist reliability, plus network, moderation, and communication infrastructure.
You are building a lean indie version of Twist. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Twist · indie build A team messaging tool designed around the absence of presence: threads instead of a scrolling channel, no typing indicators or online status, notifications batched rather than immediate, and a read model that lets someone return after a week and catch up without reading everything. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | threads, membership and read state are relational | | Delivery | Long polling or sockets for updates, without presence | the absence of presence is a product decision the transport must respect | | Notifications | Batched digests with per-person schedules and quiet hours | immediate notification recreates the pressure the design exists to remove | | Read model | Per-thread unread state with a catch-up ordering | returning after a week is the case this is designed for | | Search | Full text across threads with participant and date filters | in a threaded tool, search is how you find a decision rather than scrolling | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the transport and the digest worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Threads, participation and read state with full text search. - Get it: Docker or your package manager. - Verify: psql connects and full text search returns a ranked result - [ ] **A real team willing to use it for a fortnight** · free, and it costs the team some patience - Why: Every claim here is behavioural rather than technical, and a threaded tool with two users behaves nothing like one with eight. The catch-up design in particular can only be judged by someone who was genuinely away. - Get it: A small team who will use it as their actual channel for two weeks, including at least one person taking a week off during it. - Verify: At least five people used it for a fortnight and one returned from a week away - [ ] **Your position on urgency, written down** · free - Why: Every request to add immediate notification will be reasonable in isolation, and the accumulation of reasonable exceptions is how this becomes an ordinary chat tool. - Get it: Write down what counts as urgent in your team, and what people should do instead when something genuinely cannot wait. - Verify: A written note states the urgency policy and the alternative channel for real emergencies - [ ] **A way to send digest emails** · free tiers cover a small team - Why: The notification design is the product, and it cannot be evaluated without digests actually arriving on schedule. - Get it: Any transactional email provider, or a local mail catcher for development. - Verify: A digest arrives at the scheduled time and respects quiet hours ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. - cross-platform apps, notifications, email integration, search, and Doist reliability - network effects - native mobile apps - video and voice infrastructure - enterprise moderation and discovery If one of those is essential to you, that is the reason to keep paying for Twist, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Twist The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me team communication organised into threads, where nobody is expected to be available to replace Twist. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · threads, membership and read state are relational - Delivery: Long polling or sockets for updates, without presence · the absence of presence is a product decision the transport must respect - Notifications: Batched digests with per-person schedules and quiet hours · immediate notification recreates the pressure the design exists to remove - Read model: Per-thread unread state with a catch-up ordering · returning after a week is the case this is designed for - Search: Full text across threads with participant and date filters · in a threaded tool, search is how you find a decision rather than scrolling ### Data model (create this before Phase 1) - workspaces(id, name, created_at) - channels(id, workspace_id, name, purpose, archived_at) - threads(id, channel_id, title, created_by, created_at, last_activity_at, resolved_at) - messages(id, thread_id, author_id, body, at, edited_at) - thread_participants(thread_id, user_id, joined_at, muted, left_at) · joining a thread is deliberate, not automatic - reads(user_id, thread_id, last_read_message_id, at) - digests(user_id, scheduled_for, sent_at, thread_ids_json) · notification as a batch, not an event - There is deliberately no presence table, no typing indicator and no last-seen timestamp, because storing them makes showing them a later decision rather than an impossibility. ### Phase 1 · Threads with titles, not a scrolling channel Build: Channels containing threads, where starting a conversation requires a title. Messages belong to threads only. Participation is by joining a thread rather than by being in the channel. Trap: Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. Done when: A message cannot exist outside a thread, enforced by the schema. Starting a thread requires a title. Being in a channel does not make you a participant in its threads. A thread can be resolved and resolved threads leave the active list without being deleted. Do not build yet: No notifications, read state or search. ### Phase 2 · Removing the things that create pressure Build: Build the real-time transport without presence, typing indicators or last-seen. Message timestamps show a date rather than a precise time for older messages. No read receipts between people. Trap: Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. Done when: No presence, typing or last-seen data exists anywhere in the schema or transport, verified by inspection. A colleague's availability cannot be inferred from the interface. Read state is personal and not visible to others, verified by checking the API responses. Message delivery still works promptly for someone with the application open. Do not build yet: No notifications or search yet. ### Phase 3 · Notifications as a batch, not an interruption Build: Batch notifications into digests on a per-person schedule with quiet hours. Allow a genuine urgent path that is deliberately awkward to use. Never notify on every message. Trap: Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. Done when: Notifications arrive as digests on the recipient's schedule rather than per message. Quiet hours defer rather than dropping. An urgent notification is possible, requires a deliberate action, and shows the sender chose to interrupt. There is no setting that turns on per-message notification. Do not build yet: No read model or search yet. ### Phase 4 · Coming back after a week Build: Unread state per thread with an ordering that surfaces threads you participate in and threads that concluded without you. Allow marking a thread caught up without reading every message. Show what changed rather than replaying everything. Trap: Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. Done when: A week of activity is presented as threads ordered by relevance rather than as a message count. Threads the person participates in are surfaced above ones they do not. A thread can be skipped without reading it and does not resurface. A thread that resolved while they were away is distinguishable from one still running. Do not build yet: No search or export yet. ### Phase 5 · Finding the decision, and leaving with it Build: Full text search across threads with participant, channel and date filters. Export the whole workspace including threads, messages and participants. Retention that archives rather than deletes by default. Trap: Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. Done when: Search returns threads with the match in context rather than orphaned messages. Participant and date filters combine with text. Export contains threads, messages, participants and read state and re-imports. Archiving a channel keeps it searchable rather than removing it. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video calling, which is a substantial real-time engineering problem and also reintroduces synchronous expectation. - Integrations posting into channels, which is how a calm tool becomes a firehose of automated messages. - Mobile applications with push, where the notification design would face its hardest test. - Guest and external access with per-thread permissions, which is a permissions model on top of everything here. ### README must contain - That messages exist only inside titled threads, and the friction is deliberate - That presence, typing and last-seen are not collected rather than not displayed - That notifications are batched digests and no per-message setting exists - That catch-up orders by participation and outcome rather than chronologically - That search returns threads in context rather than orphaned messages ===== AGENTS.md ===== # Agent instructions · Twist indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Long polling or sockets for updates, without presence, Batched digests with per-person schedules and quiet hours, Per-thread unread state with a catch-up ordering, Full text across threads with participant and date filters. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ===== BUILD_PLAN.md ===== # Build plan · Twist A team messaging tool designed around the absence of presence: threads instead of a scrolling channel, no typing indicators or online status, notifications batched rather than immediate, and a read model that lets someone return after a week and catch up without reading everything. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Threads with titles, not a scrolling channel Every conversation has a subject and a boundary. ### Steps 1. Make the thread the only message container No channel-level messages. The friction is the design. Files: `src/schema.sql` 2. Require a title to start a thread A subject is what makes a conversation findable and skippable. Files: `src/threads.ts` 3. Join threads rather than channels Channel membership meaning thread membership recreates the firehose. Files: `src/participants.ts` ### Done when - [ ] A message cannot exist outside a thread - [ ] Starting a thread requires a title - [ ] Channel membership does not imply thread participation - [ ] Resolved threads leave the active list intact ### Watch out - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. ## Phase 2 · Removing the things that create pressure There is no way to know whether a colleague is at their desk. ### Steps 1. Do not collect presence at all Not stored, not transmitted. A field that exists gets displayed. Files: `src/transport.ts`, `tests/no-presence.test.ts` 2. Keep read state private Read receipts between colleagues are the same pressure by another name. Files: `src/reads.ts` 3. Soften timestamps for older messages A precise time on a two-day-old message invites reading response speed into it. Files: `src/display.ts` ### Done when - [ ] No presence data exists in schema or transport - [ ] Availability cannot be inferred from the interface - [ ] Read state is not exposed to other users - [ ] Delivery is still prompt for an open client ### Watch out - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. ## Phase 3 · Notifications as a batch, not an interruption You are told what happened on a schedule you chose. ### Steps 1. Batch into scheduled digests Per person. The recipient's schedule, not the sender's urgency. Files: `src/digests.ts` 2. Make urgency effortful and visible Possible, awkward, and attributed. That combination keeps it rare. Files: `src/urgent.ts` 3. Offer no per-message option Its absence is the product. Adding it later undoes the rest. Files: `src/notify.ts` ### Done when - [ ] Notifications arrive as scheduled digests - [ ] Quiet hours defer rather than drop - [ ] Urgent requires deliberate action and is attributed - [ ] No per-message notification setting exists ### Watch out - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. ## Phase 4 · Coming back after a week A week away is catchable up in twenty minutes, not abandoned. ### Steps 1. Order catch-up by participation and outcome Not chronologically. Chronological order is what makes a week unreadable. Files: `src/catchup.ts` 2. Make skipping a first-class action Deliberately skipping a thread is different from never seeing it. Files: `src/reads.ts` 3. Distinguish resolved from running A concluded thread needs a different amount of attention. Files: `src/threads.ts` ### Done when - [ ] A week presents as ordered threads, not a message count - [ ] Participated threads surface above others - [ ] A skipped thread does not resurface - [ ] Resolved and running threads are distinguishable ### Watch out - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ## Not in this build - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. ## After v1, if you want it - A weekly summary of resolved threads, which is what a manager actually wants - Thread ownership, so a conversation has someone responsible for concluding it - Linking a thread to whatever tracks the work it produced ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Threads and messages. A team's internal discussion, so treat backups accordingly. DATABASE_URL=postgres://localhost:5432/twist # Required. Present as a constant so the answer is visible in configuration. There is no other value. COLLECT_PRESENCE=never # Required. When digests go out by default. Each person overrides this. DIGEST_DEFAULT_TIMES=09:00,16:00 # Required. When digests defer. The default that keeps the tool calm from the first week. QUIET_HOURS_DEFAULT=18:00-08:00 # Required. How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. URGENT_DAILY_LIMIT=3 # Required · secret. Digest delivery. Without it the notification design cannot be evaluated. SMTP_URL=smtp://user:pass@host:587
You are building a lean indie version of Twist. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Twist · indie build A team messaging tool designed around the absence of presence: threads instead of a scrolling channel, no typing indicators or online status, notifications batched rather than immediate, and a read model that lets someone return after a week and catch up without reading everything. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | threads, membership and read state are relational | | Delivery | Long polling or sockets for updates, without presence | the absence of presence is a product decision the transport must respect | | Notifications | Batched digests with per-person schedules and quiet hours | immediate notification recreates the pressure the design exists to remove | | Read model | Per-thread unread state with a catch-up ordering | returning after a week is the case this is designed for | | Search | Full text across threads with participant and date filters | in a threaded tool, search is how you find a decision rather than scrolling | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the transport and the digest worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Threads, participation and read state with full text search. - Get it: Docker or your package manager. - Verify: psql connects and full text search returns a ranked result - [ ] **A real team willing to use it for a fortnight** · free, and it costs the team some patience - Why: Every claim here is behavioural rather than technical, and a threaded tool with two users behaves nothing like one with eight. The catch-up design in particular can only be judged by someone who was genuinely away. - Get it: A small team who will use it as their actual channel for two weeks, including at least one person taking a week off during it. - Verify: At least five people used it for a fortnight and one returned from a week away - [ ] **Your position on urgency, written down** · free - Why: Every request to add immediate notification will be reasonable in isolation, and the accumulation of reasonable exceptions is how this becomes an ordinary chat tool. - Get it: Write down what counts as urgent in your team, and what people should do instead when something genuinely cannot wait. - Verify: A written note states the urgency policy and the alternative channel for real emergencies - [ ] **A way to send digest emails** · free tiers cover a small team - Why: The notification design is the product, and it cannot be evaluated without digests actually arriving on schedule. - Get it: Any transactional email provider, or a local mail catcher for development. - Verify: A digest arrives at the scheduled time and respects quiet hours ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. - cross-platform apps, notifications, email integration, search, and Doist reliability - network effects - native mobile apps - video and voice infrastructure - enterprise moderation and discovery If one of those is essential to you, that is the reason to keep paying for Twist, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Twist The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me team communication organised into threads, where nobody is expected to be available to replace Twist. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · threads, membership and read state are relational - Delivery: Long polling or sockets for updates, without presence · the absence of presence is a product decision the transport must respect - Notifications: Batched digests with per-person schedules and quiet hours · immediate notification recreates the pressure the design exists to remove - Read model: Per-thread unread state with a catch-up ordering · returning after a week is the case this is designed for - Search: Full text across threads with participant and date filters · in a threaded tool, search is how you find a decision rather than scrolling ### Data model (create this before Phase 1) - workspaces(id, name, created_at) - channels(id, workspace_id, name, purpose, archived_at) - threads(id, channel_id, title, created_by, created_at, last_activity_at, resolved_at) - messages(id, thread_id, author_id, body, at, edited_at) - thread_participants(thread_id, user_id, joined_at, muted, left_at) · joining a thread is deliberate, not automatic - reads(user_id, thread_id, last_read_message_id, at) - digests(user_id, scheduled_for, sent_at, thread_ids_json) · notification as a batch, not an event - There is deliberately no presence table, no typing indicator and no last-seen timestamp, because storing them makes showing them a later decision rather than an impossibility. ### Phase 1 · Threads with titles, not a scrolling channel Build: Channels containing threads, where starting a conversation requires a title. Messages belong to threads only. Participation is by joining a thread rather than by being in the channel. Trap: Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. Done when: A message cannot exist outside a thread, enforced by the schema. Starting a thread requires a title. Being in a channel does not make you a participant in its threads. A thread can be resolved and resolved threads leave the active list without being deleted. Do not build yet: No notifications, read state or search. ### Phase 2 · Removing the things that create pressure Build: Build the real-time transport without presence, typing indicators or last-seen. Message timestamps show a date rather than a precise time for older messages. No read receipts between people. Trap: Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. Done when: No presence, typing or last-seen data exists anywhere in the schema or transport, verified by inspection. A colleague's availability cannot be inferred from the interface. Read state is personal and not visible to others, verified by checking the API responses. Message delivery still works promptly for someone with the application open. Do not build yet: No notifications or search yet. ### Phase 3 · Notifications as a batch, not an interruption Build: Batch notifications into digests on a per-person schedule with quiet hours. Allow a genuine urgent path that is deliberately awkward to use. Never notify on every message. Trap: Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. Done when: Notifications arrive as digests on the recipient's schedule rather than per message. Quiet hours defer rather than dropping. An urgent notification is possible, requires a deliberate action, and shows the sender chose to interrupt. There is no setting that turns on per-message notification. Do not build yet: No read model or search yet. ### Phase 4 · Coming back after a week Build: Unread state per thread with an ordering that surfaces threads you participate in and threads that concluded without you. Allow marking a thread caught up without reading every message. Show what changed rather than replaying everything. Trap: Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. Done when: A week of activity is presented as threads ordered by relevance rather than as a message count. Threads the person participates in are surfaced above ones they do not. A thread can be skipped without reading it and does not resurface. A thread that resolved while they were away is distinguishable from one still running. Do not build yet: No search or export yet. ### Phase 5 · Finding the decision, and leaving with it Build: Full text search across threads with participant, channel and date filters. Export the whole workspace including threads, messages and participants. Retention that archives rather than deletes by default. Trap: Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. Done when: Search returns threads with the match in context rather than orphaned messages. Participant and date filters combine with text. Export contains threads, messages, participants and read state and re-imports. Archiving a channel keeps it searchable rather than removing it. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video calling, which is a substantial real-time engineering problem and also reintroduces synchronous expectation. - Integrations posting into channels, which is how a calm tool becomes a firehose of automated messages. - Mobile applications with push, where the notification design would face its hardest test. - Guest and external access with per-thread permissions, which is a permissions model on top of everything here. ### README must contain - That messages exist only inside titled threads, and the friction is deliberate - That presence, typing and last-seen are not collected rather than not displayed - That notifications are batched digests and no per-message setting exists - That catch-up orders by participation and outcome rather than chronologically - That search returns threads in context rather than orphaned messages ===== AGENTS.md ===== # Agent instructions · Twist indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Long polling or sockets for updates, without presence, Batched digests with per-person schedules and quiet hours, Per-thread unread state with a catch-up ordering, Full text across threads with participant and date filters. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ===== BUILD_PLAN.md ===== # Build plan · Twist A team messaging tool designed around the absence of presence: threads instead of a scrolling channel, no typing indicators or online status, notifications batched rather than immediate, and a read model that lets someone return after a week and catch up without reading everything. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Threads with titles, not a scrolling channel Every conversation has a subject and a boundary. ### Steps 1. Make the thread the only message container No channel-level messages. The friction is the design. Files: `src/schema.sql` 2. Require a title to start a thread A subject is what makes a conversation findable and skippable. Files: `src/threads.ts` 3. Join threads rather than channels Channel membership meaning thread membership recreates the firehose. Files: `src/participants.ts` ### Done when - [ ] A message cannot exist outside a thread - [ ] Starting a thread requires a title - [ ] Channel membership does not imply thread participation - [ ] Resolved threads leave the active list intact ### Watch out - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. ## Phase 2 · Removing the things that create pressure There is no way to know whether a colleague is at their desk. ### Steps 1. Do not collect presence at all Not stored, not transmitted. A field that exists gets displayed. Files: `src/transport.ts`, `tests/no-presence.test.ts` 2. Keep read state private Read receipts between colleagues are the same pressure by another name. Files: `src/reads.ts` 3. Soften timestamps for older messages A precise time on a two-day-old message invites reading response speed into it. Files: `src/display.ts` ### Done when - [ ] No presence data exists in schema or transport - [ ] Availability cannot be inferred from the interface - [ ] Read state is not exposed to other users - [ ] Delivery is still prompt for an open client ### Watch out - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. ## Phase 3 · Notifications as a batch, not an interruption You are told what happened on a schedule you chose. ### Steps 1. Batch into scheduled digests Per person. The recipient's schedule, not the sender's urgency. Files: `src/digests.ts` 2. Make urgency effortful and visible Possible, awkward, and attributed. That combination keeps it rare. Files: `src/urgent.ts` 3. Offer no per-message option Its absence is the product. Adding it later undoes the rest. Files: `src/notify.ts` ### Done when - [ ] Notifications arrive as scheduled digests - [ ] Quiet hours defer rather than drop - [ ] Urgent requires deliberate action and is attributed - [ ] No per-message notification setting exists ### Watch out - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. ## Phase 4 · Coming back after a week A week away is catchable up in twenty minutes, not abandoned. ### Steps 1. Order catch-up by participation and outcome Not chronologically. Chronological order is what makes a week unreadable. Files: `src/catchup.ts` 2. Make skipping a first-class action Deliberately skipping a thread is different from never seeing it. Files: `src/reads.ts` 3. Distinguish resolved from running A concluded thread needs a different amount of attention. Files: `src/threads.ts` ### Done when - [ ] A week presents as ordered threads, not a message count - [ ] Participated threads surface above others - [ ] A skipped thread does not resurface - [ ] Resolved and running threads are distinguishable ### Watch out - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ## Not in this build - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. ## After v1, if you want it - A weekly summary of resolved threads, which is what a manager actually wants - Thread ownership, so a conversation has someone responsible for concluding it - Linking a thread to whatever tracks the work it produced ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Threads and messages. A team's internal discussion, so treat backups accordingly. DATABASE_URL=postgres://localhost:5432/twist # Required. Present as a constant so the answer is visible in configuration. There is no other value. COLLECT_PRESENCE=never # Required. When digests go out by default. Each person overrides this. DIGEST_DEFAULT_TIMES=09:00,16:00 # Required. When digests defer. The default that keeps the tool calm from the first week. QUIET_HOURS_DEFAULT=18:00-08:00 # Required. How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. URGENT_DAILY_LIMIT=3 # Required · secret. Digest delivery. Without it the notification design cannot be evaluated. SMTP_URL=smtp://user:pass@host:587
You are building a production product version of Twist. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Twist · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Twist, host asynchronous team threads organized by channel and topic. The hard boundary is cross-platform apps, notifications, email integration, search, and doist reliability, plus network, moderation, and communication infrastructure. ## Product outcome A team tool where every conversation has a subject, nobody can tell whether you are at your desk, and a week away can be caught up in twenty minutes. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email provider - object storage for attachments ## Explicit non-goals for v1 - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. - cross-platform apps, notifications, email integration, search, and Doist reliability - network effects - native mobile apps - video and voice infrastructure - enterprise moderation and discovery ## Success criteria - No presence data is confirmed present in schema or transport - A message outside a thread is confirmed impossible - No per-message notification setting is confirmed to exist - Catch-up is confirmed to order by participation rather than chronologically - Search is confirmed to return threads in context ===== BRIEF.md ===== # Build brief · Twist The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me team communication organised into threads, where nobody is expected to be available to replace Twist. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · threads, membership and read state are relational - Delivery: Long polling or sockets for updates, without presence · the absence of presence is a product decision the transport must respect - Notifications: Batched digests with per-person schedules and quiet hours · immediate notification recreates the pressure the design exists to remove - Read model: Per-thread unread state with a catch-up ordering · returning after a week is the case this is designed for - Search: Full text across threads with participant and date filters · in a threaded tool, search is how you find a decision rather than scrolling ### Data model (create this before Phase 1) - workspaces(id, name, created_at) - channels(id, workspace_id, name, purpose, archived_at) - threads(id, channel_id, title, created_by, created_at, last_activity_at, resolved_at) - messages(id, thread_id, author_id, body, at, edited_at) - thread_participants(thread_id, user_id, joined_at, muted, left_at) · joining a thread is deliberate, not automatic - reads(user_id, thread_id, last_read_message_id, at) - digests(user_id, scheduled_for, sent_at, thread_ids_json) · notification as a batch, not an event - There is deliberately no presence table, no typing indicator and no last-seen timestamp, because storing them makes showing them a later decision rather than an impossibility. ### Phase 1 · Threads with titles, not a scrolling channel Build: Channels containing threads, where starting a conversation requires a title. Messages belong to threads only. Participation is by joining a thread rather than by being in the channel. Trap: Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. Done when: A message cannot exist outside a thread, enforced by the schema. Starting a thread requires a title. Being in a channel does not make you a participant in its threads. A thread can be resolved and resolved threads leave the active list without being deleted. Do not build yet: No notifications, read state or search. ### Phase 2 · Removing the things that create pressure Build: Build the real-time transport without presence, typing indicators or last-seen. Message timestamps show a date rather than a precise time for older messages. No read receipts between people. Trap: Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. Done when: No presence, typing or last-seen data exists anywhere in the schema or transport, verified by inspection. A colleague's availability cannot be inferred from the interface. Read state is personal and not visible to others, verified by checking the API responses. Message delivery still works promptly for someone with the application open. Do not build yet: No notifications or search yet. ### Phase 3 · Notifications as a batch, not an interruption Build: Batch notifications into digests on a per-person schedule with quiet hours. Allow a genuine urgent path that is deliberately awkward to use. Never notify on every message. Trap: Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. Done when: Notifications arrive as digests on the recipient's schedule rather than per message. Quiet hours defer rather than dropping. An urgent notification is possible, requires a deliberate action, and shows the sender chose to interrupt. There is no setting that turns on per-message notification. Do not build yet: No read model or search yet. ### Phase 4 · Coming back after a week Build: Unread state per thread with an ordering that surfaces threads you participate in and threads that concluded without you. Allow marking a thread caught up without reading every message. Show what changed rather than replaying everything. Trap: Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. Done when: A week of activity is presented as threads ordered by relevance rather than as a message count. Threads the person participates in are surfaced above ones they do not. A thread can be skipped without reading it and does not resurface. A thread that resolved while they were away is distinguishable from one still running. Do not build yet: No search or export yet. ### Phase 5 · Finding the decision, and leaving with it Build: Full text search across threads with participant, channel and date filters. Export the whole workspace including threads, messages and participants. Retention that archives rather than deletes by default. Trap: Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. Done when: Search returns threads with the match in context rather than orphaned messages. Participant and date filters combine with text. Export contains threads, messages, participants and read state and re-imports. Archiving a channel keeps it searchable rather than removing it. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video calling, which is a substantial real-time engineering problem and also reintroduces synchronous expectation. - Integrations posting into channels, which is how a calm tool becomes a firehose of automated messages. - Mobile applications with push, where the notification design would face its hardest test. - Guest and external access with per-thread permissions, which is a permissions model on top of everything here. ### README must contain - That messages exist only inside titled threads, and the friction is deliberate - That presence, typing and last-seen are not collected rather than not displayed - That notifications are batched digests and no per-message setting exists - That catch-up orders by participation and outcome rather than chronologically - That search returns threads in context rather than orphaned messages ===== ARCHITECTURE.md ===== # Architecture · Twist ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | threads, membership and read state are relational | | Delivery | Long polling or sockets for updates, without presence | the absence of presence is a product decision the transport must respect | | Notifications | Batched digests with per-person schedules and quiet hours | immediate notification recreates the pressure the design exists to remove | | Read model | Per-thread unread state with a catch-up ordering | returning after a week is the case this is designed for | | Search | Full text across threads with participant and date filters | in a threaded tool, search is how you find a decision rather than scrolling | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Threads | The only container for a message. | Not swappable for channel-level messages, which become the default within a week. | | Transport | Updates without presence. | Any transport, provided presence is not collected rather than not shown. | | Digests | Batched notification on the recipient's schedule. | Any scheduler, provided no per-message option is offered. | | Catch-up | Ordering a week's absence by relevance. | Any ordering except chronological, which is what makes a week unreadable. | | Search | Threads returned in context. | Any index, provided results are threads rather than orphaned messages. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Threads and messages. A team's internal discussion, so treat backups accordingly. - `COLLECT_PRESENCE` · required · Present as a constant so the answer is visible in configuration. There is no other value. - `DIGEST_DEFAULT_TIMES` · required · When digests go out by default. Each person overrides this. - `QUIET_HOURS_DEFAULT` · required · When digests defer. The default that keeps the tool calm from the first week. - `URGENT_DAILY_LIMIT` · required · How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. - `SMTP_URL` · required, secret · Digest delivery. Without it the notification design cannot be evaluated. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Twist product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Long polling or sockets for updates, without presence, Batched digests with per-person schedules and quiet hours, Per-thread unread state with a catch-up ordering, Full text across threads with participant and date filters. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. - Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. ===== MILESTONES.md ===== # Delivery milestones · Twist Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Threads with titles, not a scrolling channel Every conversation has a subject and a boundary. ### Steps 1. Make the thread the only message container No channel-level messages. The friction is the design. Files: `src/schema.sql` 2. Require a title to start a thread A subject is what makes a conversation findable and skippable. Files: `src/threads.ts` 3. Join threads rather than channels Channel membership meaning thread membership recreates the firehose. Files: `src/participants.ts` ### Done when - [ ] A message cannot exist outside a thread - [ ] Starting a thread requires a title - [ ] Channel membership does not imply thread participation - [ ] Resolved threads leave the active list intact ### Watch out - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. ## M2 · Removing the things that create pressure There is no way to know whether a colleague is at their desk. ### Steps 1. Do not collect presence at all Not stored, not transmitted. A field that exists gets displayed. Files: `src/transport.ts`, `tests/no-presence.test.ts` 2. Keep read state private Read receipts between colleagues are the same pressure by another name. Files: `src/reads.ts` 3. Soften timestamps for older messages A precise time on a two-day-old message invites reading response speed into it. Files: `src/display.ts` ### Done when - [ ] No presence data exists in schema or transport - [ ] Availability cannot be inferred from the interface - [ ] Read state is not exposed to other users - [ ] Delivery is still prompt for an open client ### Watch out - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. ## M3 · Notifications as a batch, not an interruption You are told what happened on a schedule you chose. ### Steps 1. Batch into scheduled digests Per person. The recipient's schedule, not the sender's urgency. Files: `src/digests.ts` 2. Make urgency effortful and visible Possible, awkward, and attributed. That combination keeps it rare. Files: `src/urgent.ts` 3. Offer no per-message option Its absence is the product. Adding it later undoes the rest. Files: `src/notify.ts` ### Done when - [ ] Notifications arrive as scheduled digests - [ ] Quiet hours defer rather than drop - [ ] Urgent requires deliberate action and is attributed - [ ] No per-message notification setting exists ### Watch out - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. ## M4 · Coming back after a week A week away is catchable up in twenty minutes, not abandoned. ### Steps 1. Order catch-up by participation and outcome Not chronologically. Chronological order is what makes a week unreadable. Files: `src/catchup.ts` 2. Make skipping a first-class action Deliberately skipping a thread is different from never seeing it. Files: `src/reads.ts` 3. Distinguish resolved from running A concluded thread needs a different amount of attention. Files: `src/threads.ts` ### Done when - [ ] A week presents as ordered threads, not a message count - [ ] Participated threads surface above others - [ ] A skipped thread does not resurface - [ ] Resolved and running threads are distinguishable ### Watch out - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ## M5 · Finding the decision, and leaving with it (production only) You can find what was decided six months ago, and take the archive with you. ### Steps 1. Return threads, not messages The thread is the unit of meaning. A message alone is a fragment. Files: `src/search.ts` 2. Combine filters with text Who was in it and when matters as much as what it said. Files: `src/search.ts` 3. Export the whole workspace It is the team's record of its own decisions. Files: `src/export.ts` ### Done when - [ ] Search returns threads with matches in context - [ ] Participant and date filters combine with text - [ ] Export contains threads, messages and participants - [ ] Archived channels remain searchable ### Watch out - Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. ===== OPERATIONS.md ===== # Operations · Twist ## Backup A team's discussion history is the record of how decisions were reached and is not reconstructible. Nightly backups with a tested restore, and think about who can read them, since this is internal conversation. ## Restore Restore and confirm read state survived, since restoring without it presents every user with months of unread threads and they will mark everything read, losing the state permanently. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Urgent notification volume is the health metric for the whole design. A rising count means the calm default is not working for the team, which is worth understanding rather than accommodating with a new setting. ## Incident checklist The failure that matters is not technical: it is the accumulation of reasonable exceptions that turn this into an ordinary messenger. The written urgency stance and the absent per-message setting are the controls, and both are easy to erode. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No presence data is confirmed present in schema or transport - [ ] A message outside a thread is confirmed impossible - [ ] No per-message notification setting is confirmed to exist - [ ] Catch-up is confirmed to order by participation rather than chronologically - [ ] Search is confirmed to return threads in context ## Launch constraint Do not market omitted Twist capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Threads and messages. A team's internal discussion, so treat backups accordingly. DATABASE_URL=postgres://localhost:5432/twist # Required. Present as a constant so the answer is visible in configuration. There is no other value. COLLECT_PRESENCE=never # Required. When digests go out by default. Each person overrides this. DIGEST_DEFAULT_TIMES=09:00,16:00 # Required. When digests defer. The default that keeps the tool calm from the first week. QUIET_HOURS_DEFAULT=18:00-08:00 # Required. How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. URGENT_DAILY_LIMIT=3 # Required · secret. Digest delivery. Without it the notification design cannot be evaluated. SMTP_URL=smtp://user:pass@host:587
# Twist · indie build A team messaging tool designed around the absence of presence: threads instead of a scrolling channel, no typing indicators or online status, notifications batched rather than immediate, and a read model that lets someone return after a week and catch up without reading everything. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | threads, membership and read state are relational | | Delivery | Long polling or sockets for updates, without presence | the absence of presence is a product decision the transport must respect | | Notifications | Batched digests with per-person schedules and quiet hours | immediate notification recreates the pressure the design exists to remove | | Read model | Per-thread unread state with a catch-up ordering | returning after a week is the case this is designed for | | Search | Full text across threads with participant and date filters | in a threaded tool, search is how you find a decision rather than scrolling | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the transport and the digest worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Threads, participation and read state with full text search. - Get it: Docker or your package manager. - Verify: psql connects and full text search returns a ranked result - [ ] **A real team willing to use it for a fortnight** · free, and it costs the team some patience - Why: Every claim here is behavioural rather than technical, and a threaded tool with two users behaves nothing like one with eight. The catch-up design in particular can only be judged by someone who was genuinely away. - Get it: A small team who will use it as their actual channel for two weeks, including at least one person taking a week off during it. - Verify: At least five people used it for a fortnight and one returned from a week away - [ ] **Your position on urgency, written down** · free - Why: Every request to add immediate notification will be reasonable in isolation, and the accumulation of reasonable exceptions is how this becomes an ordinary chat tool. - Get it: Write down what counts as urgent in your team, and what people should do instead when something genuinely cannot wait. - Verify: A written note states the urgency policy and the alternative channel for real emergencies - [ ] **A way to send digest emails** · free tiers cover a small team - Why: The notification design is the product, and it cannot be evaluated without digests actually arriving on schedule. - Get it: Any transactional email provider, or a local mail catcher for development. - Verify: A digest arrives at the scheduled time and respects quiet hours ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. - cross-platform apps, notifications, email integration, search, and Doist reliability - network effects - native mobile apps - video and voice infrastructure - enterprise moderation and discovery If one of those is essential to you, that is the reason to keep paying for Twist, and the README should say so rather than pretend.
# Build brief · Twist The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me team communication organised into threads, where nobody is expected to be available to replace Twist. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · threads, membership and read state are relational - Delivery: Long polling or sockets for updates, without presence · the absence of presence is a product decision the transport must respect - Notifications: Batched digests with per-person schedules and quiet hours · immediate notification recreates the pressure the design exists to remove - Read model: Per-thread unread state with a catch-up ordering · returning after a week is the case this is designed for - Search: Full text across threads with participant and date filters · in a threaded tool, search is how you find a decision rather than scrolling ### Data model (create this before Phase 1) - workspaces(id, name, created_at) - channels(id, workspace_id, name, purpose, archived_at) - threads(id, channel_id, title, created_by, created_at, last_activity_at, resolved_at) - messages(id, thread_id, author_id, body, at, edited_at) - thread_participants(thread_id, user_id, joined_at, muted, left_at) · joining a thread is deliberate, not automatic - reads(user_id, thread_id, last_read_message_id, at) - digests(user_id, scheduled_for, sent_at, thread_ids_json) · notification as a batch, not an event - There is deliberately no presence table, no typing indicator and no last-seen timestamp, because storing them makes showing them a later decision rather than an impossibility. ### Phase 1 · Threads with titles, not a scrolling channel Build: Channels containing threads, where starting a conversation requires a title. Messages belong to threads only. Participation is by joining a thread rather than by being in the channel. Trap: Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. Done when: A message cannot exist outside a thread, enforced by the schema. Starting a thread requires a title. Being in a channel does not make you a participant in its threads. A thread can be resolved and resolved threads leave the active list without being deleted. Do not build yet: No notifications, read state or search. ### Phase 2 · Removing the things that create pressure Build: Build the real-time transport without presence, typing indicators or last-seen. Message timestamps show a date rather than a precise time for older messages. No read receipts between people. Trap: Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. Done when: No presence, typing or last-seen data exists anywhere in the schema or transport, verified by inspection. A colleague's availability cannot be inferred from the interface. Read state is personal and not visible to others, verified by checking the API responses. Message delivery still works promptly for someone with the application open. Do not build yet: No notifications or search yet. ### Phase 3 · Notifications as a batch, not an interruption Build: Batch notifications into digests on a per-person schedule with quiet hours. Allow a genuine urgent path that is deliberately awkward to use. Never notify on every message. Trap: Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. Done when: Notifications arrive as digests on the recipient's schedule rather than per message. Quiet hours defer rather than dropping. An urgent notification is possible, requires a deliberate action, and shows the sender chose to interrupt. There is no setting that turns on per-message notification. Do not build yet: No read model or search yet. ### Phase 4 · Coming back after a week Build: Unread state per thread with an ordering that surfaces threads you participate in and threads that concluded without you. Allow marking a thread caught up without reading every message. Show what changed rather than replaying everything. Trap: Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. Done when: A week of activity is presented as threads ordered by relevance rather than as a message count. Threads the person participates in are surfaced above ones they do not. A thread can be skipped without reading it and does not resurface. A thread that resolved while they were away is distinguishable from one still running. Do not build yet: No search or export yet. ### Phase 5 · Finding the decision, and leaving with it Build: Full text search across threads with participant, channel and date filters. Export the whole workspace including threads, messages and participants. Retention that archives rather than deletes by default. Trap: Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. Done when: Search returns threads with the match in context rather than orphaned messages. Participant and date filters combine with text. Export contains threads, messages, participants and read state and re-imports. Archiving a channel keeps it searchable rather than removing it. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video calling, which is a substantial real-time engineering problem and also reintroduces synchronous expectation. - Integrations posting into channels, which is how a calm tool becomes a firehose of automated messages. - Mobile applications with push, where the notification design would face its hardest test. - Guest and external access with per-thread permissions, which is a permissions model on top of everything here. ### README must contain - That messages exist only inside titled threads, and the friction is deliberate - That presence, typing and last-seen are not collected rather than not displayed - That notifications are batched digests and no per-message setting exists - That catch-up orders by participation and outcome rather than chronologically - That search returns threads in context rather than orphaned messages
# Agent instructions · Twist indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Long polling or sockets for updates, without presence, Batched digests with per-person schedules and quiet hours, Per-thread unread state with a catch-up ordering, Full text across threads with participant and date filters. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action.
# Build plan · Twist A team messaging tool designed around the absence of presence: threads instead of a scrolling channel, no typing indicators or online status, notifications batched rather than immediate, and a read model that lets someone return after a week and catch up without reading everything. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Threads with titles, not a scrolling channel Every conversation has a subject and a boundary. ### Steps 1. Make the thread the only message container No channel-level messages. The friction is the design. Files: `src/schema.sql` 2. Require a title to start a thread A subject is what makes a conversation findable and skippable. Files: `src/threads.ts` 3. Join threads rather than channels Channel membership meaning thread membership recreates the firehose. Files: `src/participants.ts` ### Done when - [ ] A message cannot exist outside a thread - [ ] Starting a thread requires a title - [ ] Channel membership does not imply thread participation - [ ] Resolved threads leave the active list intact ### Watch out - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. ## Phase 2 · Removing the things that create pressure There is no way to know whether a colleague is at their desk. ### Steps 1. Do not collect presence at all Not stored, not transmitted. A field that exists gets displayed. Files: `src/transport.ts`, `tests/no-presence.test.ts` 2. Keep read state private Read receipts between colleagues are the same pressure by another name. Files: `src/reads.ts` 3. Soften timestamps for older messages A precise time on a two-day-old message invites reading response speed into it. Files: `src/display.ts` ### Done when - [ ] No presence data exists in schema or transport - [ ] Availability cannot be inferred from the interface - [ ] Read state is not exposed to other users - [ ] Delivery is still prompt for an open client ### Watch out - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. ## Phase 3 · Notifications as a batch, not an interruption You are told what happened on a schedule you chose. ### Steps 1. Batch into scheduled digests Per person. The recipient's schedule, not the sender's urgency. Files: `src/digests.ts` 2. Make urgency effortful and visible Possible, awkward, and attributed. That combination keeps it rare. Files: `src/urgent.ts` 3. Offer no per-message option Its absence is the product. Adding it later undoes the rest. Files: `src/notify.ts` ### Done when - [ ] Notifications arrive as scheduled digests - [ ] Quiet hours defer rather than drop - [ ] Urgent requires deliberate action and is attributed - [ ] No per-message notification setting exists ### Watch out - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. ## Phase 4 · Coming back after a week A week away is catchable up in twenty minutes, not abandoned. ### Steps 1. Order catch-up by participation and outcome Not chronologically. Chronological order is what makes a week unreadable. Files: `src/catchup.ts` 2. Make skipping a first-class action Deliberately skipping a thread is different from never seeing it. Files: `src/reads.ts` 3. Distinguish resolved from running A concluded thread needs a different amount of attention. Files: `src/threads.ts` ### Done when - [ ] A week presents as ordered threads, not a message count - [ ] Participated threads surface above others - [ ] A skipped thread does not resurface - [ ] Resolved and running threads are distinguishable ### Watch out - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ## Not in this build - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. ## After v1, if you want it - A weekly summary of resolved threads, which is what a manager actually wants - Thread ownership, so a conversation has someone responsible for concluding it - Linking a thread to whatever tracks the work it produced
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Threads and messages. A team's internal discussion, so treat backups accordingly. DATABASE_URL=postgres://localhost:5432/twist # Required. Present as a constant so the answer is visible in configuration. There is no other value. COLLECT_PRESENCE=never # Required. When digests go out by default. Each person overrides this. DIGEST_DEFAULT_TIMES=09:00,16:00 # Required. When digests defer. The default that keeps the tool calm from the first week. QUIET_HOURS_DEFAULT=18:00-08:00 # Required. How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. URGENT_DAILY_LIMIT=3 # Required · secret. Digest delivery. Without it the notification design cannot be evaluated. SMTP_URL=smtp://user:pass@host:587
# Twist · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Twist, host asynchronous team threads organized by channel and topic. The hard boundary is cross-platform apps, notifications, email integration, search, and doist reliability, plus network, moderation, and communication infrastructure. ## Product outcome A team tool where every conversation has a subject, nobody can tell whether you are at your desk, and a week away can be caught up in twenty minutes. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email provider - object storage for attachments ## Explicit non-goals for v1 - Voice and video calling. - Integrations posting into channels. - Mobile applications with push. - Guest access with per-thread permissions. - cross-platform apps, notifications, email integration, search, and Doist reliability - network effects - native mobile apps - video and voice infrastructure - enterprise moderation and discovery ## Success criteria - No presence data is confirmed present in schema or transport - A message outside a thread is confirmed impossible - No per-message notification setting is confirmed to exist - Catch-up is confirmed to order by participation rather than chronologically - Search is confirmed to return threads in context
# Build brief · Twist The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me team communication organised into threads, where nobody is expected to be available to replace Twist. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · threads, membership and read state are relational - Delivery: Long polling or sockets for updates, without presence · the absence of presence is a product decision the transport must respect - Notifications: Batched digests with per-person schedules and quiet hours · immediate notification recreates the pressure the design exists to remove - Read model: Per-thread unread state with a catch-up ordering · returning after a week is the case this is designed for - Search: Full text across threads with participant and date filters · in a threaded tool, search is how you find a decision rather than scrolling ### Data model (create this before Phase 1) - workspaces(id, name, created_at) - channels(id, workspace_id, name, purpose, archived_at) - threads(id, channel_id, title, created_by, created_at, last_activity_at, resolved_at) - messages(id, thread_id, author_id, body, at, edited_at) - thread_participants(thread_id, user_id, joined_at, muted, left_at) · joining a thread is deliberate, not automatic - reads(user_id, thread_id, last_read_message_id, at) - digests(user_id, scheduled_for, sent_at, thread_ids_json) · notification as a batch, not an event - There is deliberately no presence table, no typing indicator and no last-seen timestamp, because storing them makes showing them a later decision rather than an impossibility. ### Phase 1 · Threads with titles, not a scrolling channel Build: Channels containing threads, where starting a conversation requires a title. Messages belong to threads only. Participation is by joining a thread rather than by being in the channel. Trap: Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. Done when: A message cannot exist outside a thread, enforced by the schema. Starting a thread requires a title. Being in a channel does not make you a participant in its threads. A thread can be resolved and resolved threads leave the active list without being deleted. Do not build yet: No notifications, read state or search. ### Phase 2 · Removing the things that create pressure Build: Build the real-time transport without presence, typing indicators or last-seen. Message timestamps show a date rather than a precise time for older messages. No read receipts between people. Trap: Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. Done when: No presence, typing or last-seen data exists anywhere in the schema or transport, verified by inspection. A colleague's availability cannot be inferred from the interface. Read state is personal and not visible to others, verified by checking the API responses. Message delivery still works promptly for someone with the application open. Do not build yet: No notifications or search yet. ### Phase 3 · Notifications as a batch, not an interruption Build: Batch notifications into digests on a per-person schedule with quiet hours. Allow a genuine urgent path that is deliberately awkward to use. Never notify on every message. Trap: Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. Done when: Notifications arrive as digests on the recipient's schedule rather than per message. Quiet hours defer rather than dropping. An urgent notification is possible, requires a deliberate action, and shows the sender chose to interrupt. There is no setting that turns on per-message notification. Do not build yet: No read model or search yet. ### Phase 4 · Coming back after a week Build: Unread state per thread with an ordering that surfaces threads you participate in and threads that concluded without you. Allow marking a thread caught up without reading every message. Show what changed rather than replaying everything. Trap: Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. Done when: A week of activity is presented as threads ordered by relevance rather than as a message count. Threads the person participates in are surfaced above ones they do not. A thread can be skipped without reading it and does not resurface. A thread that resolved while they were away is distinguishable from one still running. Do not build yet: No search or export yet. ### Phase 5 · Finding the decision, and leaving with it Build: Full text search across threads with participant, channel and date filters. Export the whole workspace including threads, messages and participants. Retention that archives rather than deletes by default. Trap: Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place. Done when: Search returns threads with the match in context rather than orphaned messages. Participant and date filters combine with text. Export contains threads, messages, participants and read state and re-imports. Archiving a channel keeps it searchable rather than removing it. Do not build yet: Nothing after this. ### Out of scope (and why) - Voice and video calling, which is a substantial real-time engineering problem and also reintroduces synchronous expectation. - Integrations posting into channels, which is how a calm tool becomes a firehose of automated messages. - Mobile applications with push, where the notification design would face its hardest test. - Guest and external access with per-thread permissions, which is a permissions model on top of everything here. ### README must contain - That messages exist only inside titled threads, and the friction is deliberate - That presence, typing and last-seen are not collected rather than not displayed - That notifications are batched digests and no per-message setting exists - That catch-up orders by participation and outcome rather than chronologically - That search returns threads in context rather than orphaned messages
# Architecture · Twist ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | threads, membership and read state are relational | | Delivery | Long polling or sockets for updates, without presence | the absence of presence is a product decision the transport must respect | | Notifications | Batched digests with per-person schedules and quiet hours | immediate notification recreates the pressure the design exists to remove | | Read model | Per-thread unread state with a catch-up ordering | returning after a week is the case this is designed for | | Search | Full text across threads with participant and date filters | in a threaded tool, search is how you find a decision rather than scrolling | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Threads | The only container for a message. | Not swappable for channel-level messages, which become the default within a week. | | Transport | Updates without presence. | Any transport, provided presence is not collected rather than not shown. | | Digests | Batched notification on the recipient's schedule. | Any scheduler, provided no per-message option is offered. | | Catch-up | Ordering a week's absence by relevance. | Any ordering except chronological, which is what makes a week unreadable. | | Search | Threads returned in context. | Any index, provided results are threads rather than orphaned messages. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Threads and messages. A team's internal discussion, so treat backups accordingly. - `COLLECT_PRESENCE` · required · Present as a constant so the answer is visible in configuration. There is no other value. - `DIGEST_DEFAULT_TIMES` · required · When digests go out by default. Each person overrides this. - `QUIET_HOURS_DEFAULT` · required · When digests defer. The default that keeps the tool calm from the first week. - `URGENT_DAILY_LIMIT` · required · How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. - `SMTP_URL` · required, secret · Digest delivery. Without it the notification design cannot be evaluated. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Twist product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Long polling or sockets for updates, without presence, Batched digests with per-person schedules and quiet hours, Per-thread unread state with a catch-up ordering, Full text across threads with participant and date filters. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. - Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place.
# Delivery milestones · Twist Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Threads with titles, not a scrolling channel Every conversation has a subject and a boundary. ### Steps 1. Make the thread the only message container No channel-level messages. The friction is the design. Files: `src/schema.sql` 2. Require a title to start a thread A subject is what makes a conversation findable and skippable. Files: `src/threads.ts` 3. Join threads rather than channels Channel membership meaning thread membership recreates the firehose. Files: `src/participants.ts` ### Done when - [ ] A message cannot exist outside a thread - [ ] Starting a thread requires a title - [ ] Channel membership does not imply thread participation - [ ] Resolved threads leave the active list intact ### Watch out - Allowing messages directly in a channel alongside threads means the channel becomes the default and threads become the exception within a week, which is the tool you were trying not to build. Make the thread the only container for a message, even though it adds friction to starting one. ## M2 · Removing the things that create pressure There is no way to know whether a colleague is at their desk. ### Steps 1. Do not collect presence at all Not stored, not transmitted. A field that exists gets displayed. Files: `src/transport.ts`, `tests/no-presence.test.ts` 2. Keep read state private Read receipts between colleagues are the same pressure by another name. Files: `src/reads.ts` 3. Soften timestamps for older messages A precise time on a two-day-old message invites reading response speed into it. Files: `src/display.ts` ### Done when - [ ] No presence data exists in schema or transport - [ ] Availability cannot be inferred from the interface - [ ] Read state is not exposed to other users - [ ] Delivery is still prompt for an open client ### Watch out - Storing presence data because it might be useful later guarantees it will be displayed eventually, since the field exists and someone will ask for it. Do not collect it: the absence has to be structural rather than a display choice, or the product becomes an ordinary messenger in six months. ## M3 · Notifications as a batch, not an interruption You are told what happened on a schedule you chose. ### Steps 1. Batch into scheduled digests Per person. The recipient's schedule, not the sender's urgency. Files: `src/digests.ts` 2. Make urgency effortful and visible Possible, awkward, and attributed. That combination keeps it rare. Files: `src/urgent.ts` 3. Offer no per-message option Its absence is the product. Adding it later undoes the rest. Files: `src/notify.ts` ### Done when - [ ] Notifications arrive as scheduled digests - [ ] Quiet hours defer rather than drop - [ ] Urgent requires deliberate action and is attributed - [ ] No per-message notification setting exists ### Watch out - Adding an immediate notification option because someone asks for it makes it the default within a month, and then the tool has all the interruption of a chat application plus threads. Make urgency possible but effortful, and make the effort visible to the person being interrupted. ## M4 · Coming back after a week A week away is catchable up in twenty minutes, not abandoned. ### Steps 1. Order catch-up by participation and outcome Not chronologically. Chronological order is what makes a week unreadable. Files: `src/catchup.ts` 2. Make skipping a first-class action Deliberately skipping a thread is different from never seeing it. Files: `src/reads.ts` 3. Distinguish resolved from running A concluded thread needs a different amount of attention. Files: `src/threads.ts` ### Done when - [ ] A week presents as ordered threads, not a message count - [ ] Participated threads surface above others - [ ] A skipped thread does not resurface - [ ] Resolved and running threads are distinguishable ### Watch out - Presenting a week's absence as a list of four hundred unread messages guarantees the person marks everything read and misses the two things that mattered, which is the exact failure the whole design was meant to prevent. Order by whether they are a participant and whether a thread resolved, and make skipping a thread a first-class action. ## M5 · Finding the decision, and leaving with it (production only) You can find what was decided six months ago, and take the archive with you. ### Steps 1. Return threads, not messages The thread is the unit of meaning. A message alone is a fragment. Files: `src/search.ts` 2. Combine filters with text Who was in it and when matters as much as what it said. Files: `src/search.ts` 3. Export the whole workspace It is the team's record of its own decisions. Files: `src/export.ts` ### Done when - [ ] Search returns threads with matches in context - [ ] Participant and date filters combine with text - [ ] Export contains threads, messages and participants - [ ] Archived channels remain searchable ### Watch out - Search that returns individual messages out of their thread gives you a sentence with no context, which in a threaded tool is the one thing you had that a channel-based tool does not. Return the thread with the matching message highlighted in place.
# Operations · Twist ## Backup A team's discussion history is the record of how decisions were reached and is not reconstructible. Nightly backups with a tested restore, and think about who can read them, since this is internal conversation. ## Restore Restore and confirm read state survived, since restoring without it presents every user with months of unread threads and they will mark everything read, losing the state permanently. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Urgent notification volume is the health metric for the whole design. A rising count means the calm default is not working for the team, which is worth understanding rather than accommodating with a new setting. ## Incident checklist The failure that matters is not technical: it is the accumulation of reasonable exceptions that turn this into an ordinary messenger. The written urgency stance and the absent per-message setting are the controls, and both are easy to erode. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No presence data is confirmed present in schema or transport - [ ] A message outside a thread is confirmed impossible - [ ] No per-message notification setting is confirmed to exist - [ ] Catch-up is confirmed to order by participation rather than chronologically - [ ] Search is confirmed to return threads in context ## Launch constraint Do not market omitted Twist capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Threads and messages. A team's internal discussion, so treat backups accordingly. DATABASE_URL=postgres://localhost:5432/twist # Required. Present as a constant so the answer is visible in configuration. There is no other value. COLLECT_PRESENCE=never # Required. When digests go out by default. Each person overrides this. DIGEST_DEFAULT_TIMES=09:00,16:00 # Required. When digests defer. The default that keeps the tool calm from the first week. QUIET_HOURS_DEFAULT=18:00-08:00 # Required. How many urgent notifications one person may send per day. Scarcity is what preserves the meaning. URGENT_DAILY_LIMIT=3 # Required · secret. Digest delivery. Without it the notification design cannot be evaluated. SMTP_URL=smtp://user:pass@host:587
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Twist because community software is valuable when members are already there and the operator does not have to own realtime reliability and abuse response. The recurring cost buys realtime delivery, notifications, abuse handling, search, media, permissions, mobile clients, backups, and community operations, not just the visible interface.
xcross-platform apps, notifications, email integration, search, and Doist reliability
xnetwork effects
xnative mobile apps
xvideo and voice infrastructure
xenterprise moderation and discovery
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Twist →· no votes, no pay-to-list · just what's real
Twist pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 1 month of message history; 5 integrations; 5 GB storage; up to 500 internal members and 500 external guests |
| unlimited | $8/user | $6/user | Unlimited message history, integrations and storage; up to 500 internal members and 500 external guests |
free tier1 month of history; 5 integrations; 5 GB storage; up to 500 internal members plus 500 external guests
billingmonthly + annual
hidden costsAll internal team members and multi-channel guests are billable on Unlimited; single-channel guests are free. New users are prorated and removals create account credit after the refund window; a workspace cannot mix Free and Unlimited members.
verified 2026-08-12 · source ↗
Vibecode Twist
Kinda. The core of Twist is buildable in a weekend with the prompt on this page, but there are real gaps: cross-platform apps, notifications, email integration, search, and Doist reliability, network effects. Read the honest list above before committing.
How much does Twist cost?
Twist costs about $8/month (Unlimited, checked 2026-07-31), which is $96 per year.
What do I lose by replacing Twist?
Honestly: cross-platform apps, notifications, email integration, search, and Doist reliability; network effects; native mobile apps; video and voice infrastructure; enterprise moderation and discovery. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Twist?
Yes: Discourse (Long-lived topic threads with search and notifications; more public-forum-shaped than workplace-shaped.) Mattermost Team Edition (Channels and threads work, but Mattermost still rewards live chat more than Twist's deliberate silence.) Zulip (The closest free answer: streams hold channels, topics hold threads, and every reply lands somewhere deliberate.) All 4 curated free alternatives are at vibecodeit.com/twist/alternatives. The prompt is for when you want it exactly your way.