Vibecode Missinglettr
track this build4 phases, 12 steps, beginner friendly0%The visible content repurposing loop is buildable, but a credible replacement needs more than the first screen. Missinglettr earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Missinglettr. 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 ===== # Missinglettr · indie build A drip campaign planner whose hard problems are generating a year of posts that never say something the article did not, never reusing the same quote, and still being correct in November when the article has been rewritten and a token has expired. Estimated effort: **weekend**. 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 on Node 22, one process with a scheduler tick every minute | a campaign spread over a year cannot depend on a laptop being open | | Storage | SQLite, with every generated post linked to the span it came from | a post that cannot be traced to its source is the failure mode | | Ingest | The blog's own feed plus the article HTML, extracted once and hashed | the article you generated from gets edited or unpublished months later | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs, published limits, no review gate | | Generation | Extractive by default: spans copied out of the article with their offsets | a model that paraphrases a statistic invents one | | Approval | Nothing enters the send queue until a person has seen it | a year of unattended posts is a year of unattended mistakes | ## 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 planner and the scheduler in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A machine that stays on** · a few pounds a month, or free if you own the box - Why: A campaign spans months and a laptop that sleeps turns a schedule into a list of things that did not happen. - Get it: A small VPS, a home server or a single-board computer. - Verify: A post scheduled for three in the morning has sent by breakfast - [ ] **A Mastodon account with an application token** · free - Why: One of the two networks, connected the documented way with a token you can revoke on its own. - Get it: Create an application in your account's development settings with write scope for statuses and media, and copy the access token. - Verify: Posting a test status succeeds and the response carries X-RateLimit-Remaining - [ ] **A Bluesky account with an app password** · free - Why: The other network, connected with an app password rather than your main one so it can be revoked alone. - Get it: Create an app password in your account settings and use it with your handle. - Verify: A session is created and your profile resolves - [ ] **A blog feed you own** · free - Why: Campaigns are generated from your own writing, and testing against someone else's article gives you a corpus you have no right to republish from. - Get it: The RSS or Atom URL of your own blog, with at least three published posts. - Verify: The feed parses and at least three entries have full text or fetchable URLs - [ ] **Campaign length, posting curve and minimum gap** · free - Why: Each is asserted by a test, and choosing them after seeing the output means choosing whatever the output happened to do. - Get it: Write down how many days a campaign runs, how many posts it contains, and the minimum gap between two posts on the same day. - Verify: All three appear in configuration and are named in the tests ## 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: - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. - analytics history and attribution - team approvals and asset workflows - protection from API and policy changes - direct publishing reliability as platform APIs change - reliable direct publishing across networks If one of those is essential to you, that is the reason to keep paying for Missinglettr, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Missinglettr 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 a campaign planner that turns one blog post into a year of social updates you approve before any of them go out to replace Missinglettr. 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 on Node 22, one process with a scheduler tick every minute · a campaign spread over a year cannot depend on a laptop being open - Storage: SQLite, with every generated post linked to the span it came from · a post that cannot be traced to its source is the failure mode - Ingest: The blog's own feed plus the article HTML, extracted once and hashed · the article you generated from gets edited or unpublished months later - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs, published limits, no review gate - Generation: Extractive by default: spans copied out of the article with their offsets · a model that paraphrases a statistic invents one - Approval: Nothing enters the send queue until a person has seen it · a year of unattended posts is a year of unattended mistakes ### Data model (create this before Phase 1) - sources(id, url, canonical_url, feed_id, title, author, fetched_at, text, text_sha256, last_checked_at, last_status) - spans(id, source_id, start_offset, end_offset, text, kind) · kind is sentence, heading or list_item, and text is a literal slice of sources.text - campaigns(id, source_id, created_at, length_days, source_sha256_at_creation, state) · draft, approved, running, paused or finished - posts(id, campaign_id, span_id, network, body, link_url, send_at, state, approved_at, edited, attempts, remote_id, sent_at, last_error) - used_spans(campaign_id, span_id) · unique, so one quote is never scheduled twice inside a campaign - link_checks(source_id, at, status, final_url) · the article you linked to in March may not resolve in November - A post body is a span plus framing text you wrote, kept in separate columns, so the interface can always show which characters of the article the post is quoting. ### Phase 1 · Reading an article and keeping the receipts Build: Fetch an article from the feed or from its URL, extract the readable text once, and store the text with its hash. Split it into candidate spans (sentences, headings, list items) keeping the start and end offset of each. Store the canonical URL separately from the URL you fetched. Trap: Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. Done when: Every span re-slices out of the stored text at its offsets and matches byte for byte, asserted in the suite. The source hash at campaign creation is stored and a changed article is detectable without refetching. The canonical URL is stored separately and is what posts link to. Re-ingesting the same article twice does not duplicate its spans. Do not build yet: No generation, scheduling, approval or networks. ### Phase 2 · A year of posts that never repeats itself Build: Generate a campaign of a chosen length: pick spans, attach your own framing text per network, and spread the posts across the period on a curve you can see and edit. Insert into used_spans inside the same transaction as the post, so the uniqueness is enforced by the database. Trap: Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. Done when: Generating a campaign twice from the same article never schedules the same span twice, enforced by a unique constraint rather than by application code. An article with fewer usable spans than requested posts produces a shorter campaign with a stated reason. Every post shows the article text it quotes with its offsets. Framing text and quoted text are stored in separate columns and rendered distinguishably. Do not build yet: No approval, sending or networks yet. ### Phase 3 · Nothing is queued until you have read it Build: A review screen for a whole campaign: every post, its quote, its network, its time and its character count against that network's limit. Approve per post or per campaign. Editing an approved post returns it to unapproved. Only approved posts are ever selected by the sender. Trap: Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. Done when: The sender's query selects only posts with approved_at set, asserted directly against the query. Editing an approved post clears its approval, tested for body, link and send time. The review screen shows each post's grapheme count against the target network's current limit. A campaign can be paused, and pausing stops selection within one tick. Do not build yet: No network calls, media or link checking yet. ### Phase 4 · Sending inside the limits each network publishes Build: Move a due post to sending in its own transaction before any network call. Read the rate limit headers each network returns and pace against them: Mastodon documents 300 requests per five minutes per account and per IP, with media uploads at 30 per 30 minutes, and returns X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response (checked against the current documentation, which has changed before). A timed-out call is settled by asking the network what exists, never by sending again. Trap: A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row awaiting reconciliation that resolves on the next pass, tested with a fake network. The sender pauses when the remaining count from the headers reaches a stated floor and resumes after the reset time, tested with mocked headers. A 429 reschedules the post rather than retrying it. Two concurrent ticks produce one send. Do not build yet: No link checking, dead source handling or export yet. ### Phase 5 · The article moved, the token died, and it is November Build: Re-check each campaign's source on a schedule: refetch, compare the hash, and follow the link. A source that now returns an error or a redirect to an unrelated page pauses its campaign and alerts once. An authentication failure pauses the account on the first occurrence. Export every campaign and its posts as one file, and provide a plain revert that unpublishes nothing but stops everything future. Trap: A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. Done when: A source whose hash has changed shows a warning on every unsent post in its campaign, tested by editing the stored text. A source returning 404 or redirecting off-domain pauses the campaign and alerts once, tested with a fake server. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Export produces one file containing every campaign, post, span and send result, and importing it into an empty database reproduces the campaign list. Do not build yet: Nothing after this. ### Out of scope (and why) - Generating a claim the article does not make. Posts are assembled from spans copied out of the source, and any wording of your own stays in its own column and is shown as yours. - Instagram, LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project on their own. - Engagement analytics and attribution, which need per-network metrics you cannot get consistently and which tempt you into displaying a figure you estimated. - Curating other people's articles without their attribution intact; if you add curation later, the author and the canonical link travel with every post. - Browser automation or cookie-driven posting on any network, which is how an account gets suspended. ### README must contain - That posts quote the article literally by offset, and that anything not quoted was written by you - That a campaign records the article's hash at creation, and a changed article warns on every unsent post - That one span is never scheduled twice, enforced by a unique constraint - That pacing comes from each network's returned rate limit headers, never from a hard-coded figure - Which networks are excluded and the specific reason for each ===== AGENTS.md ===== # Agent instructions · Missinglettr indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, one process with a scheduler tick every minute, SQLite, with every generated post linked to the span it came from, The blog's own feed plus the article HTML, extracted once and hashed, Mastodon and Bluesky only, one module each behind one publish function, Extractive by default: spans copied out of the article with their offsets, Nothing enters the send queue until a person has seen it. 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 - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ===== BUILD_PLAN.md ===== # Build plan · Missinglettr A drip campaign planner whose hard problems are generating a year of posts that never say something the article did not, never reusing the same quote, and still being correct in November when the article has been rewritten and a token has expired. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading an article and keeping the receipts Every quotable fragment is stored as a literal slice of the article with its offsets. ### Steps 1. Extract once, store text and hash Every later decision compares against this exact text. Files: `src/ingest/extract.ts`, `src/db/schema.sql` 2. Keep start and end offsets on every span Offsets are what let you prove a quote is still a quote. Files: `src/ingest/spans.ts`, `tests/spans-reslice.test.ts` 3. Record the canonical URL, not the one you fetched Feeds hand out tracking URLs and redirects that die first. Files: `src/ingest/canonical.ts` ### Done when - [ ] Every span re-slices out of the stored text exactly - [ ] The source hash is stored per campaign - [ ] The canonical URL is stored and used for links - [ ] Re-ingesting does not duplicate spans ### Watch out - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. ## Phase 2 · A year of posts that never repeats itself A campaign is generated from spans, one per post, with no span used twice. ### Steps 1. Enforce span uniqueness in the schema A unique index is the only deduplication that survives a concurrent generate. Files: `src/db/schema.sql`, `src/campaign/generate.ts` 2. Shorten rather than pad a thin article Nine posts from a four-paragraph note is where the invented ones come from. Files: `src/campaign/generate.ts`, `tests/thin-article.test.ts` 3. Keep quote and framing in separate columns So the interface can show which half came from the author. Files: `src/campaign/compose.ts`, `src/templates/campaign.html` ### Done when - [ ] The same span is never scheduled twice - [ ] A thin article yields a shorter campaign with a reason - [ ] Each post shows the quoted offsets - [ ] Quote and framing are stored separately ### Watch out - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. ## Phase 3 · Nothing is queued until you have read it Approval is a state change a human makes, and it covers exactly what will be sent. ### Steps 1. Select on approved_at, not on campaign state One column, one meaning, one place to get it wrong. Files: `src/sender/select.ts`, `tests/approval.test.ts` 2. Clear approval on any material edit Body, link and time all change what gets published. Files: `src/campaign/edit.ts` 3. Show counts against the network's own limit Counted in graphemes, because an emoji is not one character. Files: `src/networks/limits.ts`, `src/templates/review.html` ### Done when - [ ] Only posts with approved_at are selected - [ ] Any material edit clears approval - [ ] Counts are shown in graphemes against the live limit - [ ] Pausing stops selection within a tick ### Watch out - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. ## Phase 4 · Sending inside the limits each network publishes A post is sent once, and a burst of due posts does not trip a rate limit. ### Steps 1. Lock the row before touching the network The whole duplicate-post protection is this transaction. Files: `src/sender/send.ts`, `tests/exactly-once.test.ts` 2. Pace from the returned rate limit headers Not from a number you typed in last year. Files: `src/networks/ratelimit.ts`, `tests/ratelimit.test.ts` 3. Reconcile unknown outcomes by asking Fetch recent posts and match before ever resending. Files: `src/sender/reconcile.ts` ### Done when - [ ] A kill between accept and record yields one post - [ ] The sender pauses at the header floor and resumes at reset - [ ] A 429 reschedules rather than retries - [ ] Concurrent ticks produce one send ### Watch out - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ## Not in this build - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. ## After v1, if you want it - A second blog feed, which needs nothing new in the sender and only a source selector in the interface - Per-network framing variants of the same span, once the quote and framing columns have proved themselves - A preview rendered per network, since the same body reads differently on each ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Sources, spans, campaigns and send results. DATABASE_PATH=./data/campaigns.db # Required. Your own blog's feed. New entries appear as candidate sources. FEED_URL=https://yourblog.example/feed.xml # Required. The server you post to. Its limits are read from its responses. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. How long a generated campaign runs. Shorter is easier to keep honest. CAMPAIGN_LENGTH_DAYS=365 # Required. How often a campaign's source article is refetched and its hash compared. SOURCE_RECHECK_DAYS=14
You are building a lean indie version of Missinglettr. 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 ===== # Missinglettr · indie build A drip campaign planner whose hard problems are generating a year of posts that never say something the article did not, never reusing the same quote, and still being correct in November when the article has been rewritten and a token has expired. Estimated effort: **weekend**. 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 on Node 22, one process with a scheduler tick every minute | a campaign spread over a year cannot depend on a laptop being open | | Storage | SQLite, with every generated post linked to the span it came from | a post that cannot be traced to its source is the failure mode | | Ingest | The blog's own feed plus the article HTML, extracted once and hashed | the article you generated from gets edited or unpublished months later | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs, published limits, no review gate | | Generation | Extractive by default: spans copied out of the article with their offsets | a model that paraphrases a statistic invents one | | Approval | Nothing enters the send queue until a person has seen it | a year of unattended posts is a year of unattended mistakes | ## 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 planner and the scheduler in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A machine that stays on** · a few pounds a month, or free if you own the box - Why: A campaign spans months and a laptop that sleeps turns a schedule into a list of things that did not happen. - Get it: A small VPS, a home server or a single-board computer. - Verify: A post scheduled for three in the morning has sent by breakfast - [ ] **A Mastodon account with an application token** · free - Why: One of the two networks, connected the documented way with a token you can revoke on its own. - Get it: Create an application in your account's development settings with write scope for statuses and media, and copy the access token. - Verify: Posting a test status succeeds and the response carries X-RateLimit-Remaining - [ ] **A Bluesky account with an app password** · free - Why: The other network, connected with an app password rather than your main one so it can be revoked alone. - Get it: Create an app password in your account settings and use it with your handle. - Verify: A session is created and your profile resolves - [ ] **A blog feed you own** · free - Why: Campaigns are generated from your own writing, and testing against someone else's article gives you a corpus you have no right to republish from. - Get it: The RSS or Atom URL of your own blog, with at least three published posts. - Verify: The feed parses and at least three entries have full text or fetchable URLs - [ ] **Campaign length, posting curve and minimum gap** · free - Why: Each is asserted by a test, and choosing them after seeing the output means choosing whatever the output happened to do. - Get it: Write down how many days a campaign runs, how many posts it contains, and the minimum gap between two posts on the same day. - Verify: All three appear in configuration and are named in the tests ## 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: - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. - analytics history and attribution - team approvals and asset workflows - protection from API and policy changes - direct publishing reliability as platform APIs change - reliable direct publishing across networks If one of those is essential to you, that is the reason to keep paying for Missinglettr, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Missinglettr 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 a campaign planner that turns one blog post into a year of social updates you approve before any of them go out to replace Missinglettr. 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 on Node 22, one process with a scheduler tick every minute · a campaign spread over a year cannot depend on a laptop being open - Storage: SQLite, with every generated post linked to the span it came from · a post that cannot be traced to its source is the failure mode - Ingest: The blog's own feed plus the article HTML, extracted once and hashed · the article you generated from gets edited or unpublished months later - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs, published limits, no review gate - Generation: Extractive by default: spans copied out of the article with their offsets · a model that paraphrases a statistic invents one - Approval: Nothing enters the send queue until a person has seen it · a year of unattended posts is a year of unattended mistakes ### Data model (create this before Phase 1) - sources(id, url, canonical_url, feed_id, title, author, fetched_at, text, text_sha256, last_checked_at, last_status) - spans(id, source_id, start_offset, end_offset, text, kind) · kind is sentence, heading or list_item, and text is a literal slice of sources.text - campaigns(id, source_id, created_at, length_days, source_sha256_at_creation, state) · draft, approved, running, paused or finished - posts(id, campaign_id, span_id, network, body, link_url, send_at, state, approved_at, edited, attempts, remote_id, sent_at, last_error) - used_spans(campaign_id, span_id) · unique, so one quote is never scheduled twice inside a campaign - link_checks(source_id, at, status, final_url) · the article you linked to in March may not resolve in November - A post body is a span plus framing text you wrote, kept in separate columns, so the interface can always show which characters of the article the post is quoting. ### Phase 1 · Reading an article and keeping the receipts Build: Fetch an article from the feed or from its URL, extract the readable text once, and store the text with its hash. Split it into candidate spans (sentences, headings, list items) keeping the start and end offset of each. Store the canonical URL separately from the URL you fetched. Trap: Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. Done when: Every span re-slices out of the stored text at its offsets and matches byte for byte, asserted in the suite. The source hash at campaign creation is stored and a changed article is detectable without refetching. The canonical URL is stored separately and is what posts link to. Re-ingesting the same article twice does not duplicate its spans. Do not build yet: No generation, scheduling, approval or networks. ### Phase 2 · A year of posts that never repeats itself Build: Generate a campaign of a chosen length: pick spans, attach your own framing text per network, and spread the posts across the period on a curve you can see and edit. Insert into used_spans inside the same transaction as the post, so the uniqueness is enforced by the database. Trap: Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. Done when: Generating a campaign twice from the same article never schedules the same span twice, enforced by a unique constraint rather than by application code. An article with fewer usable spans than requested posts produces a shorter campaign with a stated reason. Every post shows the article text it quotes with its offsets. Framing text and quoted text are stored in separate columns and rendered distinguishably. Do not build yet: No approval, sending or networks yet. ### Phase 3 · Nothing is queued until you have read it Build: A review screen for a whole campaign: every post, its quote, its network, its time and its character count against that network's limit. Approve per post or per campaign. Editing an approved post returns it to unapproved. Only approved posts are ever selected by the sender. Trap: Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. Done when: The sender's query selects only posts with approved_at set, asserted directly against the query. Editing an approved post clears its approval, tested for body, link and send time. The review screen shows each post's grapheme count against the target network's current limit. A campaign can be paused, and pausing stops selection within one tick. Do not build yet: No network calls, media or link checking yet. ### Phase 4 · Sending inside the limits each network publishes Build: Move a due post to sending in its own transaction before any network call. Read the rate limit headers each network returns and pace against them: Mastodon documents 300 requests per five minutes per account and per IP, with media uploads at 30 per 30 minutes, and returns X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response (checked against the current documentation, which has changed before). A timed-out call is settled by asking the network what exists, never by sending again. Trap: A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row awaiting reconciliation that resolves on the next pass, tested with a fake network. The sender pauses when the remaining count from the headers reaches a stated floor and resumes after the reset time, tested with mocked headers. A 429 reschedules the post rather than retrying it. Two concurrent ticks produce one send. Do not build yet: No link checking, dead source handling or export yet. ### Phase 5 · The article moved, the token died, and it is November Build: Re-check each campaign's source on a schedule: refetch, compare the hash, and follow the link. A source that now returns an error or a redirect to an unrelated page pauses its campaign and alerts once. An authentication failure pauses the account on the first occurrence. Export every campaign and its posts as one file, and provide a plain revert that unpublishes nothing but stops everything future. Trap: A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. Done when: A source whose hash has changed shows a warning on every unsent post in its campaign, tested by editing the stored text. A source returning 404 or redirecting off-domain pauses the campaign and alerts once, tested with a fake server. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Export produces one file containing every campaign, post, span and send result, and importing it into an empty database reproduces the campaign list. Do not build yet: Nothing after this. ### Out of scope (and why) - Generating a claim the article does not make. Posts are assembled from spans copied out of the source, and any wording of your own stays in its own column and is shown as yours. - Instagram, LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project on their own. - Engagement analytics and attribution, which need per-network metrics you cannot get consistently and which tempt you into displaying a figure you estimated. - Curating other people's articles without their attribution intact; if you add curation later, the author and the canonical link travel with every post. - Browser automation or cookie-driven posting on any network, which is how an account gets suspended. ### README must contain - That posts quote the article literally by offset, and that anything not quoted was written by you - That a campaign records the article's hash at creation, and a changed article warns on every unsent post - That one span is never scheduled twice, enforced by a unique constraint - That pacing comes from each network's returned rate limit headers, never from a hard-coded figure - Which networks are excluded and the specific reason for each ===== AGENTS.md ===== # Agent instructions · Missinglettr indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, one process with a scheduler tick every minute, SQLite, with every generated post linked to the span it came from, The blog's own feed plus the article HTML, extracted once and hashed, Mastodon and Bluesky only, one module each behind one publish function, Extractive by default: spans copied out of the article with their offsets, Nothing enters the send queue until a person has seen it. 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 - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ===== BUILD_PLAN.md ===== # Build plan · Missinglettr A drip campaign planner whose hard problems are generating a year of posts that never say something the article did not, never reusing the same quote, and still being correct in November when the article has been rewritten and a token has expired. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading an article and keeping the receipts Every quotable fragment is stored as a literal slice of the article with its offsets. ### Steps 1. Extract once, store text and hash Every later decision compares against this exact text. Files: `src/ingest/extract.ts`, `src/db/schema.sql` 2. Keep start and end offsets on every span Offsets are what let you prove a quote is still a quote. Files: `src/ingest/spans.ts`, `tests/spans-reslice.test.ts` 3. Record the canonical URL, not the one you fetched Feeds hand out tracking URLs and redirects that die first. Files: `src/ingest/canonical.ts` ### Done when - [ ] Every span re-slices out of the stored text exactly - [ ] The source hash is stored per campaign - [ ] The canonical URL is stored and used for links - [ ] Re-ingesting does not duplicate spans ### Watch out - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. ## Phase 2 · A year of posts that never repeats itself A campaign is generated from spans, one per post, with no span used twice. ### Steps 1. Enforce span uniqueness in the schema A unique index is the only deduplication that survives a concurrent generate. Files: `src/db/schema.sql`, `src/campaign/generate.ts` 2. Shorten rather than pad a thin article Nine posts from a four-paragraph note is where the invented ones come from. Files: `src/campaign/generate.ts`, `tests/thin-article.test.ts` 3. Keep quote and framing in separate columns So the interface can show which half came from the author. Files: `src/campaign/compose.ts`, `src/templates/campaign.html` ### Done when - [ ] The same span is never scheduled twice - [ ] A thin article yields a shorter campaign with a reason - [ ] Each post shows the quoted offsets - [ ] Quote and framing are stored separately ### Watch out - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. ## Phase 3 · Nothing is queued until you have read it Approval is a state change a human makes, and it covers exactly what will be sent. ### Steps 1. Select on approved_at, not on campaign state One column, one meaning, one place to get it wrong. Files: `src/sender/select.ts`, `tests/approval.test.ts` 2. Clear approval on any material edit Body, link and time all change what gets published. Files: `src/campaign/edit.ts` 3. Show counts against the network's own limit Counted in graphemes, because an emoji is not one character. Files: `src/networks/limits.ts`, `src/templates/review.html` ### Done when - [ ] Only posts with approved_at are selected - [ ] Any material edit clears approval - [ ] Counts are shown in graphemes against the live limit - [ ] Pausing stops selection within a tick ### Watch out - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. ## Phase 4 · Sending inside the limits each network publishes A post is sent once, and a burst of due posts does not trip a rate limit. ### Steps 1. Lock the row before touching the network The whole duplicate-post protection is this transaction. Files: `src/sender/send.ts`, `tests/exactly-once.test.ts` 2. Pace from the returned rate limit headers Not from a number you typed in last year. Files: `src/networks/ratelimit.ts`, `tests/ratelimit.test.ts` 3. Reconcile unknown outcomes by asking Fetch recent posts and match before ever resending. Files: `src/sender/reconcile.ts` ### Done when - [ ] A kill between accept and record yields one post - [ ] The sender pauses at the header floor and resumes at reset - [ ] A 429 reschedules rather than retries - [ ] Concurrent ticks produce one send ### Watch out - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ## Not in this build - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. ## After v1, if you want it - A second blog feed, which needs nothing new in the sender and only a source selector in the interface - Per-network framing variants of the same span, once the quote and framing columns have proved themselves - A preview rendered per network, since the same body reads differently on each ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Sources, spans, campaigns and send results. DATABASE_PATH=./data/campaigns.db # Required. Your own blog's feed. New entries appear as candidate sources. FEED_URL=https://yourblog.example/feed.xml # Required. The server you post to. Its limits are read from its responses. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. How long a generated campaign runs. Shorter is easier to keep honest. CAMPAIGN_LENGTH_DAYS=365 # Required. How often a campaign's source article is refetched and its hash compared. SOURCE_RECHECK_DAYS=14
You are building a production product version of Missinglettr. 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 ===== # Missinglettr · product brief ## Problem The visible content repurposing loop is buildable, but a credible replacement needs more than the first screen. Missinglettr earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A planner that turns your own writing into a year of posts you have actually read, quotes the article rather than paraphrasing it, never uses the same sentence twice, and stops itself when the article or the credential goes away. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - hosted database and worker - official platform OAuth credentials where available ## Explicit non-goals for v1 - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. - analytics history and attribution - team approvals and asset workflows - protection from API and policy changes - direct publishing reliability as platform APIs change - reliable direct publishing across networks ## Success criteria - Every span is confirmed to re-slice out of the stored text - The same span is confirmed never scheduled twice - An edit is confirmed to clear approval - A kill between accept and record is confirmed to yield one post - A changed source is confirmed to warn on every unsent post ===== BRIEF.md ===== # Build brief · Missinglettr 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 a campaign planner that turns one blog post into a year of social updates you approve before any of them go out to replace Missinglettr. 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 on Node 22, one process with a scheduler tick every minute · a campaign spread over a year cannot depend on a laptop being open - Storage: SQLite, with every generated post linked to the span it came from · a post that cannot be traced to its source is the failure mode - Ingest: The blog's own feed plus the article HTML, extracted once and hashed · the article you generated from gets edited or unpublished months later - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs, published limits, no review gate - Generation: Extractive by default: spans copied out of the article with their offsets · a model that paraphrases a statistic invents one - Approval: Nothing enters the send queue until a person has seen it · a year of unattended posts is a year of unattended mistakes ### Data model (create this before Phase 1) - sources(id, url, canonical_url, feed_id, title, author, fetched_at, text, text_sha256, last_checked_at, last_status) - spans(id, source_id, start_offset, end_offset, text, kind) · kind is sentence, heading or list_item, and text is a literal slice of sources.text - campaigns(id, source_id, created_at, length_days, source_sha256_at_creation, state) · draft, approved, running, paused or finished - posts(id, campaign_id, span_id, network, body, link_url, send_at, state, approved_at, edited, attempts, remote_id, sent_at, last_error) - used_spans(campaign_id, span_id) · unique, so one quote is never scheduled twice inside a campaign - link_checks(source_id, at, status, final_url) · the article you linked to in March may not resolve in November - A post body is a span plus framing text you wrote, kept in separate columns, so the interface can always show which characters of the article the post is quoting. ### Phase 1 · Reading an article and keeping the receipts Build: Fetch an article from the feed or from its URL, extract the readable text once, and store the text with its hash. Split it into candidate spans (sentences, headings, list items) keeping the start and end offset of each. Store the canonical URL separately from the URL you fetched. Trap: Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. Done when: Every span re-slices out of the stored text at its offsets and matches byte for byte, asserted in the suite. The source hash at campaign creation is stored and a changed article is detectable without refetching. The canonical URL is stored separately and is what posts link to. Re-ingesting the same article twice does not duplicate its spans. Do not build yet: No generation, scheduling, approval or networks. ### Phase 2 · A year of posts that never repeats itself Build: Generate a campaign of a chosen length: pick spans, attach your own framing text per network, and spread the posts across the period on a curve you can see and edit. Insert into used_spans inside the same transaction as the post, so the uniqueness is enforced by the database. Trap: Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. Done when: Generating a campaign twice from the same article never schedules the same span twice, enforced by a unique constraint rather than by application code. An article with fewer usable spans than requested posts produces a shorter campaign with a stated reason. Every post shows the article text it quotes with its offsets. Framing text and quoted text are stored in separate columns and rendered distinguishably. Do not build yet: No approval, sending or networks yet. ### Phase 3 · Nothing is queued until you have read it Build: A review screen for a whole campaign: every post, its quote, its network, its time and its character count against that network's limit. Approve per post or per campaign. Editing an approved post returns it to unapproved. Only approved posts are ever selected by the sender. Trap: Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. Done when: The sender's query selects only posts with approved_at set, asserted directly against the query. Editing an approved post clears its approval, tested for body, link and send time. The review screen shows each post's grapheme count against the target network's current limit. A campaign can be paused, and pausing stops selection within one tick. Do not build yet: No network calls, media or link checking yet. ### Phase 4 · Sending inside the limits each network publishes Build: Move a due post to sending in its own transaction before any network call. Read the rate limit headers each network returns and pace against them: Mastodon documents 300 requests per five minutes per account and per IP, with media uploads at 30 per 30 minutes, and returns X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response (checked against the current documentation, which has changed before). A timed-out call is settled by asking the network what exists, never by sending again. Trap: A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row awaiting reconciliation that resolves on the next pass, tested with a fake network. The sender pauses when the remaining count from the headers reaches a stated floor and resumes after the reset time, tested with mocked headers. A 429 reschedules the post rather than retrying it. Two concurrent ticks produce one send. Do not build yet: No link checking, dead source handling or export yet. ### Phase 5 · The article moved, the token died, and it is November Build: Re-check each campaign's source on a schedule: refetch, compare the hash, and follow the link. A source that now returns an error or a redirect to an unrelated page pauses its campaign and alerts once. An authentication failure pauses the account on the first occurrence. Export every campaign and its posts as one file, and provide a plain revert that unpublishes nothing but stops everything future. Trap: A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. Done when: A source whose hash has changed shows a warning on every unsent post in its campaign, tested by editing the stored text. A source returning 404 or redirecting off-domain pauses the campaign and alerts once, tested with a fake server. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Export produces one file containing every campaign, post, span and send result, and importing it into an empty database reproduces the campaign list. Do not build yet: Nothing after this. ### Out of scope (and why) - Generating a claim the article does not make. Posts are assembled from spans copied out of the source, and any wording of your own stays in its own column and is shown as yours. - Instagram, LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project on their own. - Engagement analytics and attribution, which need per-network metrics you cannot get consistently and which tempt you into displaying a figure you estimated. - Curating other people's articles without their attribution intact; if you add curation later, the author and the canonical link travel with every post. - Browser automation or cookie-driven posting on any network, which is how an account gets suspended. ### README must contain - That posts quote the article literally by offset, and that anything not quoted was written by you - That a campaign records the article's hash at creation, and a changed article warns on every unsent post - That one span is never scheduled twice, enforced by a unique constraint - That pacing comes from each network's returned rate limit headers, never from a hard-coded figure - Which networks are excluded and the specific reason for each ===== ARCHITECTURE.md ===== # Architecture · Missinglettr ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process with a scheduler tick every minute | a campaign spread over a year cannot depend on a laptop being open | | Storage | SQLite, with every generated post linked to the span it came from | a post that cannot be traced to its source is the failure mode | | Ingest | The blog's own feed plus the article HTML, extracted once and hashed | the article you generated from gets edited or unpublished months later | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs, published limits, no review gate | | Generation | Extractive by default: spans copied out of the article with their offsets | a model that paraphrases a statistic invents one | | Approval | Nothing enters the send queue until a person has seen it | a year of unattended posts is a year of unattended mistakes | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Extraction, hashing and spans with offsets. | Any extractor, provided spans re-slice out of the stored text exactly. | | Campaign | Generation, span uniqueness and the posting curve. | Any scheduling curve, provided used_spans stays unique. | | Approval | approved_at and what clears it. | Not swappable for a campaign-level flag, which is how unread edits get published. | | Sender | The lock, pacing and reconciliation. | Any network module, provided the row is locked before the call. | | Monitor | Source rechecks, link checks and account pausing. | Any alert channel, provided it fires once rather than per failure. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Sources, spans, campaigns and send results. - `FEED_URL` · required · Your own blog's feed. New entries appear as candidate sources. - `MASTODON_BASE_URL` · required · The server you post to. Its limits are read from its responses. - `MASTODON_ACCESS_TOKEN` · required, secret · Write scope for statuses and media only. Revoke it to test the pause. - `BLUESKY_HANDLE` · required · Your handle, used with an app password. - `BLUESKY_APP_PASSWORD` · required, secret · An app password, never your main password. - `CAMPAIGN_LENGTH_DAYS` · required · How long a generated campaign runs. Shorter is easier to keep honest. - `SOURCE_RECHECK_DAYS` · required · How often a campaign's source article is refetched and its hash compared. ## 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 · Missinglettr product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22, one process with a scheduler tick every minute, SQLite, with every generated post linked to the span it came from, The blog's own feed plus the article HTML, extracted once and hashed, Mastodon and Bluesky only, one module each behind one publish function, Extractive by default: spans copied out of the article with their offsets, Nothing enters the send queue until a person has seen it. - 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 - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. - A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. ===== MILESTONES.md ===== # Delivery milestones · Missinglettr Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Reading an article and keeping the receipts Every quotable fragment is stored as a literal slice of the article with its offsets. ### Steps 1. Extract once, store text and hash Every later decision compares against this exact text. Files: `src/ingest/extract.ts`, `src/db/schema.sql` 2. Keep start and end offsets on every span Offsets are what let you prove a quote is still a quote. Files: `src/ingest/spans.ts`, `tests/spans-reslice.test.ts` 3. Record the canonical URL, not the one you fetched Feeds hand out tracking URLs and redirects that die first. Files: `src/ingest/canonical.ts` ### Done when - [ ] Every span re-slices out of the stored text exactly - [ ] The source hash is stored per campaign - [ ] The canonical URL is stored and used for links - [ ] Re-ingesting does not duplicate spans ### Watch out - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. ## M2 · A year of posts that never repeats itself A campaign is generated from spans, one per post, with no span used twice. ### Steps 1. Enforce span uniqueness in the schema A unique index is the only deduplication that survives a concurrent generate. Files: `src/db/schema.sql`, `src/campaign/generate.ts` 2. Shorten rather than pad a thin article Nine posts from a four-paragraph note is where the invented ones come from. Files: `src/campaign/generate.ts`, `tests/thin-article.test.ts` 3. Keep quote and framing in separate columns So the interface can show which half came from the author. Files: `src/campaign/compose.ts`, `src/templates/campaign.html` ### Done when - [ ] The same span is never scheduled twice - [ ] A thin article yields a shorter campaign with a reason - [ ] Each post shows the quoted offsets - [ ] Quote and framing are stored separately ### Watch out - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. ## M3 · Nothing is queued until you have read it Approval is a state change a human makes, and it covers exactly what will be sent. ### Steps 1. Select on approved_at, not on campaign state One column, one meaning, one place to get it wrong. Files: `src/sender/select.ts`, `tests/approval.test.ts` 2. Clear approval on any material edit Body, link and time all change what gets published. Files: `src/campaign/edit.ts` 3. Show counts against the network's own limit Counted in graphemes, because an emoji is not one character. Files: `src/networks/limits.ts`, `src/templates/review.html` ### Done when - [ ] Only posts with approved_at are selected - [ ] Any material edit clears approval - [ ] Counts are shown in graphemes against the live limit - [ ] Pausing stops selection within a tick ### Watch out - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. ## M4 · Sending inside the limits each network publishes A post is sent once, and a burst of due posts does not trip a rate limit. ### Steps 1. Lock the row before touching the network The whole duplicate-post protection is this transaction. Files: `src/sender/send.ts`, `tests/exactly-once.test.ts` 2. Pace from the returned rate limit headers Not from a number you typed in last year. Files: `src/networks/ratelimit.ts`, `tests/ratelimit.test.ts` 3. Reconcile unknown outcomes by asking Fetch recent posts and match before ever resending. Files: `src/sender/reconcile.ts` ### Done when - [ ] A kill between accept and record yields one post - [ ] The sender pauses at the header floor and resumes at reset - [ ] A 429 reschedules rather than retries - [ ] Concurrent ticks produce one send ### Watch out - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ## M5 · The article moved, the token died, and it is November (production only) A campaign running for a year notices the world changing under it instead of publishing into a void. ### Steps 1. Re-check sources on a schedule and compare hashes A quote from a paragraph that no longer exists is a misquote. Files: `src/monitor/source-check.ts` 2. Pause on the first auth failure and alert once One alert beats six weeks of silence. Files: `src/sender/pause.ts`, `tests/pause.test.ts` 3. Export everything into one file Campaigns, posts, spans and outcomes, reimportable. Files: `src/export.ts`, `tests/roundtrip.test.ts` ### Done when - [ ] A changed source warns on every unsent post - [ ] A dead source pauses the campaign and alerts once - [ ] An auth failure pauses the account within a tick - [ ] Export round-trips into an empty database ### Watch out - A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. ===== OPERATIONS.md ===== # Operations · Missinglettr ## Backup The database is everything: sources, spans, campaigns and what has already been sent. Back it up before any schema change, because a restore from before a send leaves the sender willing to publish the same post again. ## Restore Restore, then move every post left in sending to awaiting reconciliation and run reconciliation before the next tick, so nothing is republished on the assumption that it failed. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Paused accounts, paused campaigns, posts awaiting reconciliation, and the age of each source's last recheck. A campaign paused three weeks ago that nobody noticed is the usual way a feed goes quiet. ## Incident checklist A duplicate post means the lock was bypassed and the attempts trail shows the two calls. A wrong quote means the source changed and the recheck did not run; both the stored text and the offsets are there to prove what was true at generation time. 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 - [ ] Every span is confirmed to re-slice out of the stored text - [ ] The same span is confirmed never scheduled twice - [ ] An edit is confirmed to clear approval - [ ] A kill between accept and record is confirmed to yield one post - [ ] A changed source is confirmed to warn on every unsent post ## Launch constraint Do not market omitted Missinglettr 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. Sources, spans, campaigns and send results. DATABASE_PATH=./data/campaigns.db # Required. Your own blog's feed. New entries appear as candidate sources. FEED_URL=https://yourblog.example/feed.xml # Required. The server you post to. Its limits are read from its responses. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. How long a generated campaign runs. Shorter is easier to keep honest. CAMPAIGN_LENGTH_DAYS=365 # Required. How often a campaign's source article is refetched and its hash compared. SOURCE_RECHECK_DAYS=14
# Missinglettr · indie build A drip campaign planner whose hard problems are generating a year of posts that never say something the article did not, never reusing the same quote, and still being correct in November when the article has been rewritten and a token has expired. Estimated effort: **weekend**. 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 on Node 22, one process with a scheduler tick every minute | a campaign spread over a year cannot depend on a laptop being open | | Storage | SQLite, with every generated post linked to the span it came from | a post that cannot be traced to its source is the failure mode | | Ingest | The blog's own feed plus the article HTML, extracted once and hashed | the article you generated from gets edited or unpublished months later | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs, published limits, no review gate | | Generation | Extractive by default: spans copied out of the article with their offsets | a model that paraphrases a statistic invents one | | Approval | Nothing enters the send queue until a person has seen it | a year of unattended posts is a year of unattended mistakes | ## 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 planner and the scheduler in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A machine that stays on** · a few pounds a month, or free if you own the box - Why: A campaign spans months and a laptop that sleeps turns a schedule into a list of things that did not happen. - Get it: A small VPS, a home server or a single-board computer. - Verify: A post scheduled for three in the morning has sent by breakfast - [ ] **A Mastodon account with an application token** · free - Why: One of the two networks, connected the documented way with a token you can revoke on its own. - Get it: Create an application in your account's development settings with write scope for statuses and media, and copy the access token. - Verify: Posting a test status succeeds and the response carries X-RateLimit-Remaining - [ ] **A Bluesky account with an app password** · free - Why: The other network, connected with an app password rather than your main one so it can be revoked alone. - Get it: Create an app password in your account settings and use it with your handle. - Verify: A session is created and your profile resolves - [ ] **A blog feed you own** · free - Why: Campaigns are generated from your own writing, and testing against someone else's article gives you a corpus you have no right to republish from. - Get it: The RSS or Atom URL of your own blog, with at least three published posts. - Verify: The feed parses and at least three entries have full text or fetchable URLs - [ ] **Campaign length, posting curve and minimum gap** · free - Why: Each is asserted by a test, and choosing them after seeing the output means choosing whatever the output happened to do. - Get it: Write down how many days a campaign runs, how many posts it contains, and the minimum gap between two posts on the same day. - Verify: All three appear in configuration and are named in the tests ## 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: - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. - analytics history and attribution - team approvals and asset workflows - protection from API and policy changes - direct publishing reliability as platform APIs change - reliable direct publishing across networks If one of those is essential to you, that is the reason to keep paying for Missinglettr, and the README should say so rather than pretend.
# Build brief · Missinglettr 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 a campaign planner that turns one blog post into a year of social updates you approve before any of them go out to replace Missinglettr. 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 on Node 22, one process with a scheduler tick every minute · a campaign spread over a year cannot depend on a laptop being open - Storage: SQLite, with every generated post linked to the span it came from · a post that cannot be traced to its source is the failure mode - Ingest: The blog's own feed plus the article HTML, extracted once and hashed · the article you generated from gets edited or unpublished months later - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs, published limits, no review gate - Generation: Extractive by default: spans copied out of the article with their offsets · a model that paraphrases a statistic invents one - Approval: Nothing enters the send queue until a person has seen it · a year of unattended posts is a year of unattended mistakes ### Data model (create this before Phase 1) - sources(id, url, canonical_url, feed_id, title, author, fetched_at, text, text_sha256, last_checked_at, last_status) - spans(id, source_id, start_offset, end_offset, text, kind) · kind is sentence, heading or list_item, and text is a literal slice of sources.text - campaigns(id, source_id, created_at, length_days, source_sha256_at_creation, state) · draft, approved, running, paused or finished - posts(id, campaign_id, span_id, network, body, link_url, send_at, state, approved_at, edited, attempts, remote_id, sent_at, last_error) - used_spans(campaign_id, span_id) · unique, so one quote is never scheduled twice inside a campaign - link_checks(source_id, at, status, final_url) · the article you linked to in March may not resolve in November - A post body is a span plus framing text you wrote, kept in separate columns, so the interface can always show which characters of the article the post is quoting. ### Phase 1 · Reading an article and keeping the receipts Build: Fetch an article from the feed or from its URL, extract the readable text once, and store the text with its hash. Split it into candidate spans (sentences, headings, list items) keeping the start and end offset of each. Store the canonical URL separately from the URL you fetched. Trap: Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. Done when: Every span re-slices out of the stored text at its offsets and matches byte for byte, asserted in the suite. The source hash at campaign creation is stored and a changed article is detectable without refetching. The canonical URL is stored separately and is what posts link to. Re-ingesting the same article twice does not duplicate its spans. Do not build yet: No generation, scheduling, approval or networks. ### Phase 2 · A year of posts that never repeats itself Build: Generate a campaign of a chosen length: pick spans, attach your own framing text per network, and spread the posts across the period on a curve you can see and edit. Insert into used_spans inside the same transaction as the post, so the uniqueness is enforced by the database. Trap: Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. Done when: Generating a campaign twice from the same article never schedules the same span twice, enforced by a unique constraint rather than by application code. An article with fewer usable spans than requested posts produces a shorter campaign with a stated reason. Every post shows the article text it quotes with its offsets. Framing text and quoted text are stored in separate columns and rendered distinguishably. Do not build yet: No approval, sending or networks yet. ### Phase 3 · Nothing is queued until you have read it Build: A review screen for a whole campaign: every post, its quote, its network, its time and its character count against that network's limit. Approve per post or per campaign. Editing an approved post returns it to unapproved. Only approved posts are ever selected by the sender. Trap: Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. Done when: The sender's query selects only posts with approved_at set, asserted directly against the query. Editing an approved post clears its approval, tested for body, link and send time. The review screen shows each post's grapheme count against the target network's current limit. A campaign can be paused, and pausing stops selection within one tick. Do not build yet: No network calls, media or link checking yet. ### Phase 4 · Sending inside the limits each network publishes Build: Move a due post to sending in its own transaction before any network call. Read the rate limit headers each network returns and pace against them: Mastodon documents 300 requests per five minutes per account and per IP, with media uploads at 30 per 30 minutes, and returns X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response (checked against the current documentation, which has changed before). A timed-out call is settled by asking the network what exists, never by sending again. Trap: A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row awaiting reconciliation that resolves on the next pass, tested with a fake network. The sender pauses when the remaining count from the headers reaches a stated floor and resumes after the reset time, tested with mocked headers. A 429 reschedules the post rather than retrying it. Two concurrent ticks produce one send. Do not build yet: No link checking, dead source handling or export yet. ### Phase 5 · The article moved, the token died, and it is November Build: Re-check each campaign's source on a schedule: refetch, compare the hash, and follow the link. A source that now returns an error or a redirect to an unrelated page pauses its campaign and alerts once. An authentication failure pauses the account on the first occurrence. Export every campaign and its posts as one file, and provide a plain revert that unpublishes nothing but stops everything future. Trap: A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. Done when: A source whose hash has changed shows a warning on every unsent post in its campaign, tested by editing the stored text. A source returning 404 or redirecting off-domain pauses the campaign and alerts once, tested with a fake server. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Export produces one file containing every campaign, post, span and send result, and importing it into an empty database reproduces the campaign list. Do not build yet: Nothing after this. ### Out of scope (and why) - Generating a claim the article does not make. Posts are assembled from spans copied out of the source, and any wording of your own stays in its own column and is shown as yours. - Instagram, LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project on their own. - Engagement analytics and attribution, which need per-network metrics you cannot get consistently and which tempt you into displaying a figure you estimated. - Curating other people's articles without their attribution intact; if you add curation later, the author and the canonical link travel with every post. - Browser automation or cookie-driven posting on any network, which is how an account gets suspended. ### README must contain - That posts quote the article literally by offset, and that anything not quoted was written by you - That a campaign records the article's hash at creation, and a changed article warns on every unsent post - That one span is never scheduled twice, enforced by a unique constraint - That pacing comes from each network's returned rate limit headers, never from a hard-coded figure - Which networks are excluded and the specific reason for each
# Agent instructions · Missinglettr indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, one process with a scheduler tick every minute, SQLite, with every generated post linked to the span it came from, The blog's own feed plus the article HTML, extracted once and hashed, Mastodon and Bluesky only, one module each behind one publish function, Extractive by default: spans copied out of the article with their offsets, Nothing enters the send queue until a person has seen it. 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 - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry.
# Build plan · Missinglettr A drip campaign planner whose hard problems are generating a year of posts that never say something the article did not, never reusing the same quote, and still being correct in November when the article has been rewritten and a token has expired. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading an article and keeping the receipts Every quotable fragment is stored as a literal slice of the article with its offsets. ### Steps 1. Extract once, store text and hash Every later decision compares against this exact text. Files: `src/ingest/extract.ts`, `src/db/schema.sql` 2. Keep start and end offsets on every span Offsets are what let you prove a quote is still a quote. Files: `src/ingest/spans.ts`, `tests/spans-reslice.test.ts` 3. Record the canonical URL, not the one you fetched Feeds hand out tracking URLs and redirects that die first. Files: `src/ingest/canonical.ts` ### Done when - [ ] Every span re-slices out of the stored text exactly - [ ] The source hash is stored per campaign - [ ] The canonical URL is stored and used for links - [ ] Re-ingesting does not duplicate spans ### Watch out - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. ## Phase 2 · A year of posts that never repeats itself A campaign is generated from spans, one per post, with no span used twice. ### Steps 1. Enforce span uniqueness in the schema A unique index is the only deduplication that survives a concurrent generate. Files: `src/db/schema.sql`, `src/campaign/generate.ts` 2. Shorten rather than pad a thin article Nine posts from a four-paragraph note is where the invented ones come from. Files: `src/campaign/generate.ts`, `tests/thin-article.test.ts` 3. Keep quote and framing in separate columns So the interface can show which half came from the author. Files: `src/campaign/compose.ts`, `src/templates/campaign.html` ### Done when - [ ] The same span is never scheduled twice - [ ] A thin article yields a shorter campaign with a reason - [ ] Each post shows the quoted offsets - [ ] Quote and framing are stored separately ### Watch out - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. ## Phase 3 · Nothing is queued until you have read it Approval is a state change a human makes, and it covers exactly what will be sent. ### Steps 1. Select on approved_at, not on campaign state One column, one meaning, one place to get it wrong. Files: `src/sender/select.ts`, `tests/approval.test.ts` 2. Clear approval on any material edit Body, link and time all change what gets published. Files: `src/campaign/edit.ts` 3. Show counts against the network's own limit Counted in graphemes, because an emoji is not one character. Files: `src/networks/limits.ts`, `src/templates/review.html` ### Done when - [ ] Only posts with approved_at are selected - [ ] Any material edit clears approval - [ ] Counts are shown in graphemes against the live limit - [ ] Pausing stops selection within a tick ### Watch out - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. ## Phase 4 · Sending inside the limits each network publishes A post is sent once, and a burst of due posts does not trip a rate limit. ### Steps 1. Lock the row before touching the network The whole duplicate-post protection is this transaction. Files: `src/sender/send.ts`, `tests/exactly-once.test.ts` 2. Pace from the returned rate limit headers Not from a number you typed in last year. Files: `src/networks/ratelimit.ts`, `tests/ratelimit.test.ts` 3. Reconcile unknown outcomes by asking Fetch recent posts and match before ever resending. Files: `src/sender/reconcile.ts` ### Done when - [ ] A kill between accept and record yields one post - [ ] The sender pauses at the header floor and resumes at reset - [ ] A 429 reschedules rather than retries - [ ] Concurrent ticks produce one send ### Watch out - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ## Not in this build - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. ## After v1, if you want it - A second blog feed, which needs nothing new in the sender and only a source selector in the interface - Per-network framing variants of the same span, once the quote and framing columns have proved themselves - A preview rendered per network, since the same body reads differently on each
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Sources, spans, campaigns and send results. DATABASE_PATH=./data/campaigns.db # Required. Your own blog's feed. New entries appear as candidate sources. FEED_URL=https://yourblog.example/feed.xml # Required. The server you post to. Its limits are read from its responses. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. How long a generated campaign runs. Shorter is easier to keep honest. CAMPAIGN_LENGTH_DAYS=365 # Required. How often a campaign's source article is refetched and its hash compared. SOURCE_RECHECK_DAYS=14
# Missinglettr · product brief ## Problem The visible content repurposing loop is buildable, but a credible replacement needs more than the first screen. Missinglettr earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A planner that turns your own writing into a year of posts you have actually read, quotes the article rather than paraphrasing it, never uses the same sentence twice, and stops itself when the article or the credential goes away. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - hosted database and worker - official platform OAuth credentials where available ## Explicit non-goals for v1 - Instagram, LinkedIn and TikTok. - Engagement analytics and attribution. - Curation of other people's content. - Any browser automation. - analytics history and attribution - team approvals and asset workflows - protection from API and policy changes - direct publishing reliability as platform APIs change - reliable direct publishing across networks ## Success criteria - Every span is confirmed to re-slice out of the stored text - The same span is confirmed never scheduled twice - An edit is confirmed to clear approval - A kill between accept and record is confirmed to yield one post - A changed source is confirmed to warn on every unsent post
# Build brief · Missinglettr 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 a campaign planner that turns one blog post into a year of social updates you approve before any of them go out to replace Missinglettr. 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 on Node 22, one process with a scheduler tick every minute · a campaign spread over a year cannot depend on a laptop being open - Storage: SQLite, with every generated post linked to the span it came from · a post that cannot be traced to its source is the failure mode - Ingest: The blog's own feed plus the article HTML, extracted once and hashed · the article you generated from gets edited or unpublished months later - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs, published limits, no review gate - Generation: Extractive by default: spans copied out of the article with their offsets · a model that paraphrases a statistic invents one - Approval: Nothing enters the send queue until a person has seen it · a year of unattended posts is a year of unattended mistakes ### Data model (create this before Phase 1) - sources(id, url, canonical_url, feed_id, title, author, fetched_at, text, text_sha256, last_checked_at, last_status) - spans(id, source_id, start_offset, end_offset, text, kind) · kind is sentence, heading or list_item, and text is a literal slice of sources.text - campaigns(id, source_id, created_at, length_days, source_sha256_at_creation, state) · draft, approved, running, paused or finished - posts(id, campaign_id, span_id, network, body, link_url, send_at, state, approved_at, edited, attempts, remote_id, sent_at, last_error) - used_spans(campaign_id, span_id) · unique, so one quote is never scheduled twice inside a campaign - link_checks(source_id, at, status, final_url) · the article you linked to in March may not resolve in November - A post body is a span plus framing text you wrote, kept in separate columns, so the interface can always show which characters of the article the post is quoting. ### Phase 1 · Reading an article and keeping the receipts Build: Fetch an article from the feed or from its URL, extract the readable text once, and store the text with its hash. Split it into candidate spans (sentences, headings, list items) keeping the start and end offset of each. Store the canonical URL separately from the URL you fetched. Trap: Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. Done when: Every span re-slices out of the stored text at its offsets and matches byte for byte, asserted in the suite. The source hash at campaign creation is stored and a changed article is detectable without refetching. The canonical URL is stored separately and is what posts link to. Re-ingesting the same article twice does not duplicate its spans. Do not build yet: No generation, scheduling, approval or networks. ### Phase 2 · A year of posts that never repeats itself Build: Generate a campaign of a chosen length: pick spans, attach your own framing text per network, and spread the posts across the period on a curve you can see and edit. Insert into used_spans inside the same transaction as the post, so the uniqueness is enforced by the database. Trap: Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. Done when: Generating a campaign twice from the same article never schedules the same span twice, enforced by a unique constraint rather than by application code. An article with fewer usable spans than requested posts produces a shorter campaign with a stated reason. Every post shows the article text it quotes with its offsets. Framing text and quoted text are stored in separate columns and rendered distinguishably. Do not build yet: No approval, sending or networks yet. ### Phase 3 · Nothing is queued until you have read it Build: A review screen for a whole campaign: every post, its quote, its network, its time and its character count against that network's limit. Approve per post or per campaign. Editing an approved post returns it to unapproved. Only approved posts are ever selected by the sender. Trap: Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. Done when: The sender's query selects only posts with approved_at set, asserted directly against the query. Editing an approved post clears its approval, tested for body, link and send time. The review screen shows each post's grapheme count against the target network's current limit. A campaign can be paused, and pausing stops selection within one tick. Do not build yet: No network calls, media or link checking yet. ### Phase 4 · Sending inside the limits each network publishes Build: Move a due post to sending in its own transaction before any network call. Read the rate limit headers each network returns and pace against them: Mastodon documents 300 requests per five minutes per account and per IP, with media uploads at 30 per 30 minutes, and returns X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response (checked against the current documentation, which has changed before). A timed-out call is settled by asking the network what exists, never by sending again. Trap: A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row awaiting reconciliation that resolves on the next pass, tested with a fake network. The sender pauses when the remaining count from the headers reaches a stated floor and resumes after the reset time, tested with mocked headers. A 429 reschedules the post rather than retrying it. Two concurrent ticks produce one send. Do not build yet: No link checking, dead source handling or export yet. ### Phase 5 · The article moved, the token died, and it is November Build: Re-check each campaign's source on a schedule: refetch, compare the hash, and follow the link. A source that now returns an error or a redirect to an unrelated page pauses its campaign and alerts once. An authentication failure pauses the account on the first occurrence. Export every campaign and its posts as one file, and provide a plain revert that unpublishes nothing but stops everything future. Trap: A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log. Done when: A source whose hash has changed shows a warning on every unsent post in its campaign, tested by editing the stored text. A source returning 404 or redirecting off-domain pauses the campaign and alerts once, tested with a fake server. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Export produces one file containing every campaign, post, span and send result, and importing it into an empty database reproduces the campaign list. Do not build yet: Nothing after this. ### Out of scope (and why) - Generating a claim the article does not make. Posts are assembled from spans copied out of the source, and any wording of your own stays in its own column and is shown as yours. - Instagram, LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project on their own. - Engagement analytics and attribution, which need per-network metrics you cannot get consistently and which tempt you into displaying a figure you estimated. - Curating other people's articles without their attribution intact; if you add curation later, the author and the canonical link travel with every post. - Browser automation or cookie-driven posting on any network, which is how an account gets suspended. ### README must contain - That posts quote the article literally by offset, and that anything not quoted was written by you - That a campaign records the article's hash at creation, and a changed article warns on every unsent post - That one span is never scheduled twice, enforced by a unique constraint - That pacing comes from each network's returned rate limit headers, never from a hard-coded figure - Which networks are excluded and the specific reason for each
# Architecture · Missinglettr ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process with a scheduler tick every minute | a campaign spread over a year cannot depend on a laptop being open | | Storage | SQLite, with every generated post linked to the span it came from | a post that cannot be traced to its source is the failure mode | | Ingest | The blog's own feed plus the article HTML, extracted once and hashed | the article you generated from gets edited or unpublished months later | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs, published limits, no review gate | | Generation | Extractive by default: spans copied out of the article with their offsets | a model that paraphrases a statistic invents one | | Approval | Nothing enters the send queue until a person has seen it | a year of unattended posts is a year of unattended mistakes | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Extraction, hashing and spans with offsets. | Any extractor, provided spans re-slice out of the stored text exactly. | | Campaign | Generation, span uniqueness and the posting curve. | Any scheduling curve, provided used_spans stays unique. | | Approval | approved_at and what clears it. | Not swappable for a campaign-level flag, which is how unread edits get published. | | Sender | The lock, pacing and reconciliation. | Any network module, provided the row is locked before the call. | | Monitor | Source rechecks, link checks and account pausing. | Any alert channel, provided it fires once rather than per failure. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Sources, spans, campaigns and send results. - `FEED_URL` · required · Your own blog's feed. New entries appear as candidate sources. - `MASTODON_BASE_URL` · required · The server you post to. Its limits are read from its responses. - `MASTODON_ACCESS_TOKEN` · required, secret · Write scope for statuses and media only. Revoke it to test the pause. - `BLUESKY_HANDLE` · required · Your handle, used with an app password. - `BLUESKY_APP_PASSWORD` · required, secret · An app password, never your main password. - `CAMPAIGN_LENGTH_DAYS` · required · How long a generated campaign runs. Shorter is easier to keep honest. - `SOURCE_RECHECK_DAYS` · required · How often a campaign's source article is refetched and its hash compared. ## 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 · Missinglettr product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22, one process with a scheduler tick every minute, SQLite, with every generated post linked to the span it came from, The blog's own feed plus the article HTML, extracted once and hashed, Mastodon and Bluesky only, one module each behind one publish function, Extractive by default: spans copied out of the article with their offsets, Nothing enters the send queue until a person has seen it. - 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 - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. - A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log.
# Delivery milestones · Missinglettr Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Reading an article and keeping the receipts Every quotable fragment is stored as a literal slice of the article with its offsets. ### Steps 1. Extract once, store text and hash Every later decision compares against this exact text. Files: `src/ingest/extract.ts`, `src/db/schema.sql` 2. Keep start and end offsets on every span Offsets are what let you prove a quote is still a quote. Files: `src/ingest/spans.ts`, `tests/spans-reslice.test.ts` 3. Record the canonical URL, not the one you fetched Feeds hand out tracking URLs and redirects that die first. Files: `src/ingest/canonical.ts` ### Done when - [ ] Every span re-slices out of the stored text exactly - [ ] The source hash is stored per campaign - [ ] The canonical URL is stored and used for links - [ ] Re-ingesting does not duplicate spans ### Watch out - Storing the extracted quote without its offsets means that in six months, when the article has been rewritten, nothing can tell you whether the post about to go out still reflects the page. Keep offsets and the text hash, and mark any campaign whose source hash has moved since it was created. ## M2 · A year of posts that never repeats itself A campaign is generated from spans, one per post, with no span used twice. ### Steps 1. Enforce span uniqueness in the schema A unique index is the only deduplication that survives a concurrent generate. Files: `src/db/schema.sql`, `src/campaign/generate.ts` 2. Shorten rather than pad a thin article Nine posts from a four-paragraph note is where the invented ones come from. Files: `src/campaign/generate.ts`, `tests/thin-article.test.ts` 3. Keep quote and framing in separate columns So the interface can show which half came from the author. Files: `src/campaign/compose.ts`, `src/templates/campaign.html` ### Done when - [ ] The same span is never scheduled twice - [ ] A thin article yields a shorter campaign with a reason - [ ] Each post shows the quoted offsets - [ ] Quote and framing are stored separately ### Watch out - Deduplicating quotes by comparing the finished post bodies lets the same sentence reappear with different framing and a different emoji, which reads as a bot to anyone following you. Deduplicate on span id with a unique constraint, and when the article has fewer usable spans than the campaign length, shorten the campaign and say so. ## M3 · Nothing is queued until you have read it Approval is a state change a human makes, and it covers exactly what will be sent. ### Steps 1. Select on approved_at, not on campaign state One column, one meaning, one place to get it wrong. Files: `src/sender/select.ts`, `tests/approval.test.ts` 2. Clear approval on any material edit Body, link and time all change what gets published. Files: `src/campaign/edit.ts` 3. Show counts against the network's own limit Counted in graphemes, because an emoji is not one character. Files: `src/networks/limits.ts`, `src/templates/review.html` ### Done when - [ ] Only posts with approved_at are selected - [ ] Any material edit clears approval - [ ] Counts are shown in graphemes against the live limit - [ ] Pausing stops selection within a tick ### Watch out - Approving a campaign and then letting an edit sail through keeps the approved flag on a post nobody has read in its current form, which is how a draft note to yourself ends up published. Clear the approval on any edit to body, link or time, and make the sender's query select on approved_at rather than on a campaign state. ## M4 · Sending inside the limits each network publishes A post is sent once, and a burst of due posts does not trip a rate limit. ### Steps 1. Lock the row before touching the network The whole duplicate-post protection is this transaction. Files: `src/sender/send.ts`, `tests/exactly-once.test.ts` 2. Pace from the returned rate limit headers Not from a number you typed in last year. Files: `src/networks/ratelimit.ts`, `tests/ratelimit.test.ts` 3. Reconcile unknown outcomes by asking Fetch recent posts and match before ever resending. Files: `src/sender/reconcile.ts` ### Done when - [ ] A kill between accept and record yields one post - [ ] The sender pauses at the header floor and resumes at reset - [ ] A 429 reschedules rather than retries - [ ] Concurrent ticks produce one send ### Watch out - A campaign that stacks twenty posts on a launch day sends them in a loop, exhausts the media allowance a third of the way through and then retries the failures into a longer block. Pace from the returned remaining count and reset time, spread same-day posts by a stated minimum gap, and treat a 429 as a reschedule rather than a retry. ## M5 · The article moved, the token died, and it is November (production only) A campaign running for a year notices the world changing under it instead of publishing into a void. ### Steps 1. Re-check sources on a schedule and compare hashes A quote from a paragraph that no longer exists is a misquote. Files: `src/monitor/source-check.ts` 2. Pause on the first auth failure and alert once One alert beats six weeks of silence. Files: `src/sender/pause.ts`, `tests/pause.test.ts` 3. Export everything into one file Campaigns, posts, spans and outcomes, reimportable. Files: `src/export.ts`, `tests/roundtrip.test.ts` ### Done when - [ ] A changed source warns on every unsent post - [ ] A dead source pauses the campaign and alerts once - [ ] An auth failure pauses the account within a tick - [ ] Export round-trips into an empty database ### Watch out - A token expires in August and every remaining post fails quietly at its slot, so the failure shows up as a six-week gap somebody notices in October. Pause the account on the first authentication failure, alert once, and show the paused state on the campaign list rather than only in a log.
# Operations · Missinglettr ## Backup The database is everything: sources, spans, campaigns and what has already been sent. Back it up before any schema change, because a restore from before a send leaves the sender willing to publish the same post again. ## Restore Restore, then move every post left in sending to awaiting reconciliation and run reconciliation before the next tick, so nothing is republished on the assumption that it failed. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Paused accounts, paused campaigns, posts awaiting reconciliation, and the age of each source's last recheck. A campaign paused three weeks ago that nobody noticed is the usual way a feed goes quiet. ## Incident checklist A duplicate post means the lock was bypassed and the attempts trail shows the two calls. A wrong quote means the source changed and the recheck did not run; both the stored text and the offsets are there to prove what was true at generation time. 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 - [ ] Every span is confirmed to re-slice out of the stored text - [ ] The same span is confirmed never scheduled twice - [ ] An edit is confirmed to clear approval - [ ] A kill between accept and record is confirmed to yield one post - [ ] A changed source is confirmed to warn on every unsent post ## Launch constraint Do not market omitted Missinglettr 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. Sources, spans, campaigns and send results. DATABASE_PATH=./data/campaigns.db # Required. Your own blog's feed. New entries appear as candidate sources. FEED_URL=https://yourblog.example/feed.xml # Required. The server you post to. Its limits are read from its responses. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. How long a generated campaign runs. Shorter is easier to keep honest. CAMPAIGN_LENGTH_DAYS=365 # Required. How often a campaign's source article is refetched and its hash compared. SOURCE_RECHECK_DAYS=14
$ choose a build depth, inspect the files, then open the complete pack in your agent
Missinglettr: Social tools survive because networks change APIs, permissions, formats, and anti-spam rules constantly.
xanalytics history and attribution
xteam approvals and asset workflows
xprotection from API and policy changes
xdirect publishing reliability as platform APIs change
xreliable direct publishing across networks
Missinglettr pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| solo | $15 | $12.50 | 1 workspace, 1 extra user, 3 social profiles, 100 AI post + 100 AI article + 100 AI image generations |
| pro | $39 | $32.50 | 3 workspaces, 10 social profiles, 500 AI post + 500 AI article + 500 AI image generations |
| agency | $147 | $122.50 | Unlimited workspaces and users, 25 social profiles, 10,000 scheduled posts, unlimited Curate posts |
| enterprise | custom | — | Custom increased capacity, integrations, account management and features |
free tierno free tier
billingmonthly + annual (2 months free); all paid plans include a free trial with no card required
hidden costsAgency includes 25 social profiles, then charges an unpublished add-on rate for more; mid-cycle plan changes are prorated.
verified 2026-08-14 · source ↗
Vibecode Missinglettr
Kinda. The core of Missinglettr is buildable in a weekend with the prompt on this page, but there are real gaps: analytics history and attribution, team approvals and asset workflows. Read the honest list above before committing.
How much does Missinglettr cost?
Missinglettr's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Missinglettr?
Honestly: analytics history and attribution; team approvals and asset workflows; protection from API and policy changes; direct publishing reliability as platform APIs change; reliable direct publishing across networks. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Missinglettr?
Yes: Postiz (Open-source social scheduling and publishing platform.), Mixpost (Self-hosted social media management software.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.