Features, in detail

You build. We distribute.

The short version is on the home page. This is the long one: what actually happens between the merge button and the post.

The pipeline

From merge to draft

You install a GitHub App on the repos you want covered. When a pull request is merged, GitHub delivers a webhook. The signature is verified before anything else happens — constant-time, length-guarded — and the delivery id is recorded, so GitHub redelivering the same event does not produce a second post. The webhook enqueues a job and returns straight away; nothing slow happens inside the request.

A worker claims the job with a row-locked database call, so two workers cannot pick up the same merge at the same time (a job whose worker died is reclaimed later, deliberately). The pull-request row is written before any fallible work — a merge always shows up in your feed even if drafting later fails, rather than vanishing. Failures retry with a cap of five attempts; a reconciler sweeps every five minutes for jobs that died mid-flight.

Two passes, not one

Drafting is deliberately split. The first pass reads the diff (trimmed to 12,000 characters) with Claude Haiku 4.5 and produces a plain technical summary of what actually changed. The second pass runs Claude Sonnet 5 once per channel and voice, assembling that summary together with your persona, your voice, your repo config, and the channel’s own format brief.

Both artefacts are kept on the draft: the stage-one summary and the exact stage-two prompt. When a post reads oddly, you can see which input caused it instead of guessing.

The filter ladder

What never becomes a post

Most merges are not announcements. Before a token is spent, every merged PR runs down a ladder and the first match wins:

  1. A marketing:skip label — the manual override.
  2. Any skip label you configured for the repo.
  3. A path rule marked publish: never, or a PR that asked to be skipped in its description.
  4. Bot authors — dependency bumps announce themselves to nobody.
  5. A diff that is only lockfiles, generated files, or formatting: counted as zero meaningful lines and dropped.
  6. A marketing:launch label, which routes the PR to launch framing instead of a routine update.
  7. Fewer than ten meaningful changed lines: held for the weekly roundup rather than posted alone.
  8. Everything else gets drafted.

The decision is recorded on the pull-request row with its reason, so “why didn’t this one post?” is a question the app answers rather than a mystery.

Voices

Who is speaking

A voice is an identity, and it is separate from your audience. A brand voice speaks as the company. A person voice speaks in first-person singular, gets the PR description as story material, and carries its own story hooks, off-limits list and disclosure line — the last two are never model-written.

Because drafting fans out per (channel, voice) pair, one merge can produce two genuinely different posts: the company account announcing a feature, and you saying what it was like to build it. Grouping is by resolved voice rather than by account, so two accounts that share a voice get one draft rather than duplicates.

How a voice is chosen

  1. The voice bound to the connected account itself — always wins, and no repo config can override it.
  2. A per-channel voice in the repo config.
  3. The repo’s default voice (a PR hint can override this one).
  4. Your account’s default voice.
  5. The built-in brand voice.

A named voice that does not exist holds that channel and says so — it never quietly falls back to a different identity. And a person voice clamps the repo down to review-before-publish: copy written as a human never auto-publishes, whatever the repo asked for.

Personas

Who is being spoken to

Point the persona generator at your product site and it fetches the homepage, pricing and about pages in parallel, then produces a structured ideal-customer profile: who they are, what hurts, what they want, the vocabulary they use, the objections they raise, and the tone that lands.

Fetching a URL somebody typed is a security problem, so the address is checked before every request and again after each redirect: public http(s) only, with loopback, private ranges and the cloud metadata endpoint refused, and IPv4-mapped IPv6 normalised before the check rather than after. An eight-second timeout and a streamed two-megabyte cap bound what one run can cost. If every page fails to load, the run throws rather than overwriting a good persona with an empty one.

Repos name a persona by string, and paths within a repo can name a different one — infrastructure work and product work rarely address the same reader.

Channels

Sized the way each network counts

Every channel is written to that network’s real ceiling, counted the way that network counts — which is not the same as counting characters.

ChannelLimitThe catch
X280Weighted: Latin and common punctuation cost 1, CJK and emoji cost 2. Links bill a flat 23.
Bluesky300Grapheme clusters — an emoji with a skin-tone modifier is one. Links count literally.
Mastodon500Links bill a flat 23.
LinkedIn3,000Aims far under the cap; nobody reads 3,000 characters.
Discord2,000Posted through a channel webhook — and a webhook is not a Nitro subscriber, so 2,000 is the number that binds. No hashtags: on Discord they read as spam.
ChangelogUncappedNo hashtags — an entry is not a social post.

Copy is budgeted before it is written (the cap minus room for hashtags), measured after, and only if it is still over does it get one follow-up call asking for it shorter. A hard trim at a word boundary is the last resort, not the first. The same measuring code runs in the browser, so the character count you see while editing is the one the publisher will enforce.

Connections

It posts as you, not as us

There is no aggregator in the middle. You connect your own accounts and Merge & Tell publishes to them directly:

  • Bluesky — a handle and an app password. No app registration, nothing to approve; the fastest way to try it.
  • Mastodon — OAuth against your own instance; the per-instance client is registered once and cached.
  • LinkedIn — OAuth, posting to your member feed.
  • X — OAuth2 with PKCE. X bills per post, so the estimated cost of each post is priced and recorded on the queue row.
  • Discord — a channel webhook URL, which is the credential: no identity, no session, no expiry.

Where the secrets live

Tokens, app passwords and client secrets are stored encrypted in Supabase Vault, reachable only by the server. The table your browser can read holds metadata and nothing else. Deleting a connection destroys its secrets through a database trigger rather than hoping application code remembers.

A connection is active, needs_reauth (fails fast instead of retrying into a wall) or revoked. Every connect, test, disconnect and token-going-stale event is both logged and written to an Activity feed you can read on the screen — because a silent credential failure is the most expensive lie this app could tell. Tokens that renew themselves are not flagged as broken; only the ones that need you are.

Social sets

Which accounts a repo posts to

A social set is a named group of connected accounts — “company accounts”, “the side project”, “founder only”. A repo names one in its config; without one it uses your account default, and without that, every active connection.

The failure mode here matters more than the feature. A repo that names a set which no longer exists publishes nothing and tells you why — it never falls back to “everywhere”. A repo whose config could not be read is shown as unreadable, not as unconstrained. The routing tree on the accounts screen shows, per repo, exactly where a post would land before you approve one.

The queue

When it goes out, and the stop button

Approving a draft creates one queue row per target, snapshotting the exact text that will be sent. You choose now, a specific time, or the next free slot in the posting schedule you set — real wall-clock times in your own time zone, so an 09:00 slot stays 09:00 across daylight saving.

A publisher runs every minute. Rows claimed by a worker that died are reclaimed after ten minutes. Failures back off on a fixed ladder — one minute, five, fifteen, an hour, three hours — up to five attempts, and a network asking us to wait longer is obeyed.

Not sending the same post twice

Three layers, in order:

  1. Claiming a row is a conditional update, so two workers can never dispatch the same row at the same time. (A row whose worker died mid-send is reclaimed later — which is what the next layer is for.)
  2. Before a retry, the network is asked whether the earlier attempt already landed. Mastodon dedupes server-side on a key we supply; X and Bluesky get a lookup. “I could not check” is treated as “do not send” — LinkedIn cannot be checked at all, since reading your posts back requires a permission only approved apps get.
  3. The remote post id is written in the same update that marks the row published, so a row already carrying one is adopted, never re-sent.

Only Mastodon is exactly-once in the strict sense. The rest is layered mitigation, and it is described that way here on purpose.

One switch on the settings screen pauses the entire account. It cancels nothing already published and does not burn the retry budget — it sits in front of the send. Individual rows can be cancelled, retried, or unpublished from the network, with the row kept as history.

Configuration

It lives in the repo

A .marketing.yml on the default branch carries the product name and URL, the default posting mode, persona, voice, hashtags and free-form instructions, a glossary of terms to keep straight, per-path areas that override those defaults, the social set to post as, the networks to narrow to, and the labels that mean “skip”.

Any pull request can override that for itself with a marketing block in its description — a different persona, a different framing, extra hashtags. With one guard: the posting mode a PR asks for can only ever be stricter. A PR can ask to be held for review; it can never promote itself to auto-publish. Reaching auto-publish takes write access to the repo’s own config, which is exactly the point — on a public repo, anyone can open a pull request.

If you would rather not commit a file, the in-app editor writes the same settings and takes precedence over the committed one. Neither present is fine; you get sensible defaults.

Repositories

Hiding one, completely

Every synced repo is listed on one screen, and each has a hide switch that makes three promises and enforces all of them:

  • Records nothing. The webhook acknowledges the merge and drops it before a job exists — and a job created before you hid it re-checks and stops too.
  • Publishes nothing. The gate sits directly in front of the queue, so approving, publishing now, scheduling and the roundup all refuse with a reason. Already-queued posts for that repo are cancelled, and the count is shown.
  • Appears nowhere. Not in the PR feed, the batch view, the preview tool, the persona picker or the routing tree.

Unhiding restores everything from that moment. Merges that happened while it was hidden were never recorded and cancelled posts do not come back — which the screen says out loud rather than letting you discover it.

Weekly roundup

Small merges add up instead of piling up

A PR under the meaningful-lines threshold is recorded and held rather than drafted. On Monday — or whenever you press flush — the held PRs across all your repos are claimed atomically and drafted as a single “this week we shipped” post per channel, linked back to every PR it covers.

A roundup is always a draft. It never auto-publishes, because a post spanning several repos is exactly the one you want to read first.

Composer

Posts that were not pull requests

Sometimes the thing to say did not come from a merge. The composer writes one, with channel chips showing how many accounts each one reaches under the selected set, a live per-network preview, and one body or a different one per channel.

It goes through exactly the same routing, sets, voices, schedule and queue as generated copy — and over-limit text is rejected before anything is written, for every channel at once. Hand-written posts are excluded from the kept-rate metric, so they cannot flatter the numbers, but they are used as voice samples: what you wrote yourself is the truest sample of how you sound.

Attribution

Tagged links, opt-in

Add a UTM block to your config and links get campaign tags. By default only links to your own product get tagged, the source is the network the post is going to, and a link that already carries its own tags is left exactly as written.

Tagging happens per target as the post is queued, not at the last moment — so the tagged text is what gets length-checked. That order matters: Bluesky counts link text literally, so a post that fits before tagging can fail after it.

Changelog

A feed you own

The changelog is a channel with no network behind it. The same drafts render as a public Atom feed and a JSON Feed at your own URL — the format is in the path, not a header, so each is its own cache key and each entry keeps the same id across both. Switching format does not re-announce your history.

Entries come from what was actually published, in publish order: editing a draft afterwards does not rewrite history. PR-sourced entries link back to the pull request. Bodies are text, never HTML. The feed is the newest fifty entries — a feed, not an archive.

The screen also generates a prompt you can paste into an agent that should watch your releases, carrying your own feed URL and the four things such an agent otherwise gets wrong.

Stats

Whether any of this is working

Two numbers, both about generated copy only. Kept-rate is the share of decided drafts you actually published rather than discarded — undecided drafts are excluded from the denominator, so the number does not drift upward just because a queue is backing up.

The second is how much you edited before publishing: an edit ratio — the edit distance between what was generated and what went out, over the length of the longer of the two — measured at publish time and shown as an average percentage. Zero means it went out verbatim, and lower is better. If the drafts are bad, both numbers say so, which is the point of showing them.

That is the whole machine.

You were going to merge the PR anyway.

How Merge & Tell works