Build Missinglettr

KINDAback to the verdict

0%0 of 18 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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 weekendthe files for this build are in the project pack

RuntimeTypeScript on Node 22, one process with a scheduler tick every minuteStorageSQLite, with every generated post linked to the span it came fromIngestThe blog's own feed plus the article HTML, extracted once and hashedNetworksMastodon and Bluesky only, one module each behind one publish functionGenerationExtractive by default: spans copied out of the article with their offsetsApprovalNothing enters the send queue until a person has seen it

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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. open ↗

    Verify Posting a test status succeeds and the response carries X-RateLimit-Remaining

  4. 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. open ↗

    Verify A session is created and your profile resolves

  5. 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

  6. 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.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/campaigns.dbSources, spans, campaigns and send results.
FEED_URLrequiredhttps://yourblog.example/feed.xmlYour own blog's feed. New entries appear as candidate sources.
MASTODON_BASE_URLrequiredhttps://mastodon.exampleThe server you post to. Its limits are read from its responses.
MASTODON_ACCESS_TOKENsecretrequireda token from your application settingsWrite scope for statuses and media only. Revoke it to test the pause.
BLUESKY_HANDLErequiredyou.bsky.socialYour handle, used with an app password.
BLUESKY_APP_PASSWORDsecretrequiredxxxx-xxxx-xxxx-xxxxAn app password, never your main password.
CAMPAIGN_LENGTH_DAYSrequired365How long a generated campaign runs. Shorter is easier to keep honest.
SOURCE_RECHECK_DAYSrequired14How often a campaign's source article is refetched and its hash compared.

The build, in order

  1. Reading an article and keeping the receipts

    Every quotable fragment is stored as a literal slice of the article with its offsets.

    1. Every later decision compares against this exact text.

      Files src/ingest/extract.tssrc/db/schema.sql

    2. Offsets are what let you prove a quote is still a quote.

      Files src/ingest/spans.tstests/spans-reslice.test.ts

    3. Feeds hand out tracking URLs and redirects that die first.

      Files src/ingest/canonical.ts

    done when · tick each as it passes
    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.
  2. A year of posts that never repeats itself

    A campaign is generated from spans, one per post, with no span used twice.

    1. A unique index is the only deduplication that survives a concurrent generate.

      Files src/db/schema.sqlsrc/campaign/generate.ts

    2. Nine posts from a four-paragraph note is where the invented ones come from.

      Files src/campaign/generate.tstests/thin-article.test.ts

    3. So the interface can show which half came from the author.

      Files src/campaign/compose.tssrc/templates/campaign.html

    done when · tick each as it passes
    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.
  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.

    1. One column, one meaning, one place to get it wrong.

      Files src/sender/select.tstests/approval.test.ts

    2. Body, link and time all change what gets published.

      Files src/campaign/edit.ts

    3. Counted in graphemes, because an emoji is not one character.

      Files src/networks/limits.tssrc/templates/review.html

    done when · tick each as it passes
    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.
  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.

    1. The whole duplicate-post protection is this transaction.

      Files src/sender/send.tstests/exactly-once.test.ts

    2. Not from a number you typed in last year.

      Files src/networks/ratelimit.tstests/ratelimit.test.ts

    3. Fetch recent posts and match before ever resending.

      Files src/sender/reconcile.ts

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.