Build Missinglettr
KINDAback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the planner and the scheduler in one process.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - have readya 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 - accountfree
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. open ↗
Verify
Posting a test status succeeds and the response carries X-RateLimit-Remaining - accountfree
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. open ↗
Verify
A session is created and your profile resolves - have readyfree
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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/campaigns.db | Sources, spans, campaigns and send results. |
FEED_URL | required | https://yourblog.example/feed.xml | Your own blog's feed. New entries appear as candidate sources. |
MASTODON_BASE_URL | required | https://mastodon.example | The server you post to. Its limits are read from its responses. |
MASTODON_ACCESS_TOKENsecret | required | a token from your application settings | Write scope for statuses and media only. Revoke it to test the pause. |
BLUESKY_HANDLE | required | you.bsky.social | Your handle, used with an app password. |
BLUESKY_APP_PASSWORDsecret | required | xxxx-xxxx-xxxx-xxxx | An app password, never your main password. |
CAMPAIGN_LENGTH_DAYS | required | 365 | How long a generated campaign runs. Shorter is easier to keep honest. |
SOURCE_RECHECK_DAYS | required | 14 | How often a campaign's source article is refetched and its hash compared. |
The build, in order
Reading an article and keeping the receipts
Every quotable fragment is stored as a literal slice of the article with its offsets.
Every later decision compares against this exact text.
Files
src/ingest/extract.tssrc/db/schema.sqlOffsets are what let you prove a quote is still a quote.
Files
src/ingest/spans.tstests/spans-reslice.test.tsFeeds hand out tracking URLs and redirects that die first.
Files
src/ingest/canonical.ts
done when · tick each as it passeswatch 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.
A year of posts that never repeats itself
A campaign is generated from spans, one per post, with no span used twice.
A unique index is the only deduplication that survives a concurrent generate.
Files
src/db/schema.sqlsrc/campaign/generate.tsNine posts from a four-paragraph note is where the invented ones come from.
Files
src/campaign/generate.tstests/thin-article.test.tsSo the interface can show which half came from the author.
Files
src/campaign/compose.tssrc/templates/campaign.html
done when · tick each as it passeswatch 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.
Nothing is queued until you have read it
Approval is a state change a human makes, and it covers exactly what will be sent.
One column, one meaning, one place to get it wrong.
Files
src/sender/select.tstests/approval.test.tsBody, link and time all change what gets published.
Files
src/campaign/edit.tsCounted in graphemes, because an emoji is not one character.
Files
src/networks/limits.tssrc/templates/review.html
done when · tick each as it passeswatch 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.
Sending inside the limits each network publishes
A post is sent once, and a burst of due posts does not trip a rate limit.
The whole duplicate-post protection is this transaction.
Files
src/sender/send.tstests/exactly-once.test.tsNot from a number you typed in last year.
Files
src/networks/ratelimit.tstests/ratelimit.test.tsFetch recent posts and match before ever resending.
Files
src/sender/reconcile.ts
done when · tick each as it passeswatch 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.
The article moved, the token died, and it is Novemberproduct builder
A campaign running for a year notices the world changing under it instead of publishing into a void.
A quote from a paragraph that no longer exists is a misquote.
Files
src/monitor/source-check.tsOne alert beats six weeks of silence.
Files
src/sender/pause.tstests/pause.test.tsCampaigns, posts, spans and outcomes, reimportable.
Files
src/export.tstests/roundtrip.test.ts
done when · tick each as it passeswatch 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.
That is the whole plan for Missinglettr. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.