Features, in detail

Between the merge button and the post.

The short version is on the home page. This is the long one: every step, in the order it runs. Products, repos, ICPs, voices and sets are separate things for a reason; see how the pieces fit.

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 handler checks the signature before anything else happens, constant-time and length-guarded, then records the delivery id, 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, on purpose). The worker writes the pull-request row before any fallible work. A merge 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 runs in two passes on purpose. The first pass runs Claude Haiku 4.5 over the pull request and answers one question: what changed for the people who use the product. It reads the PR description as the primary source, because that is a human explaining intent, with the diff (trimmed to 12,000 characters) as supporting evidence, and it answers in fixed fields: who is affected, what they had to put up with before, what happens now, and why that matters. Naming a file, function or table is off-limits, because a summary written in code vocabulary produces copy written in code vocabulary.

The second pass runs Claude Sonnet 5 once per channel and voice, assembling that brief together with your ICP, your voice, your repo config, and the channel’s own format brief.

The draft keeps both artefacts: the stage-one brief 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 a batch you build rather than posted alone.
  8. Everything else gets drafted.

That ladder is structural, built out of labels, authors and line counts, so it cannot tell an internal rewrite from a new capability. One more check does. After the first pass reports what changed for users, a pull request whose honest answer is nothing reaches them stops there, in the model’s own words. A change nobody can see has no post in it, and asking for one anyway is how you get copy that invents a benefit or narrates the plumbing. A marketing:launch label overrides this, because you already answered the question.

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

ICPs

Who is being spoken to

Point the ICP 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 ICP with an empty one.

Repos name an ICP 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, so 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. We register the per-instance client once and cache it.
  • LinkedIn. OAuth, posting to your member feed.
  • X. OAuth2 with PKCE. X bills per post, so we price each post and record the estimate on its 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. 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.

Membership is many-to-many, which is what lets one company account belong to several products’ sets, or to every set at once. Why sets overlap, and what filing an account does.

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. Those are 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 of one minute, five, fifteen, an hour and three hours, up to five attempts, and we wait as long as the network asks us to.

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

Split by who changes it

A repo’s config has two owners, because its two halves change for unrelated reasons. The repo owns its GitHub plumbing in a .marketing.yml on the default branch: the labels that mean “skip”, and per-path areas that reframe or silence whole directories. Those go stale the moment code moves, so they change in the pull request that moves it.

The app owns the marketing: product name and URL, posting mode, ICP, voice, hashtags, style rules, a glossary of terms to keep straight, the social set to post as and the networks to narrow to. Changing a campaign shouldn’t need a code review or a deploy.

Any pull request can override that for itself with a marketing block in its description: a different ICP, 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 means switching it on in the app, which is the point. On a public repo, anyone can open a pull request.

Neither half is required, and neither can overwrite the other: saving a hashtag never drops a skip label, and committing a skip label never changes where a repo posts. A setting the file has no business holding is ignored and named on the repo’s page, rather than silently doing nothing.

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 publishing a batch 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 ICP 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. The screen says that out loud rather than letting you discover it.

Batches

Small merges add up instead of piling up

A PR under the meaningful-lines threshold is recorded and held rather than drafted. Nothing writes it automatically. You group those merges into a named batch, generate one update for the set, and publish it yourself. The batch is linked back to every PR it covers.

A batch 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 the same routing, sets, voices, schedule and queue as generated copy, and it rejects over-limit text 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

Switch tagging on once for the whole account and every repository inherits it. Any one of them can use its own campaign settings instead, or refuse tagging entirely. Only links to your own product get tagged, so a repository needs a product URL for tagging to reach it; the source is the network the post is going to, and a link that already carries its own tags is left exactly as written.

There is also a marker, via=mergetel, for the question your analytics otherwise cannot answer: which of this traffic did the app produce? utm_source only names the network, which a post you typed yourself would say too. Off unless you ask for it, and it never takes a label you were already using. Neither via nor utm_content is overwritten when the link already carries one.

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 rather than 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 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 published rather than discarded. Undecided drafts stay out of 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. We measure it at publish time and show it 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.

Wondering how this differs from what you already use: why it is not a scheduler, why it does not make AI video, and why it is not a general AI agent.

Shopping the ones that also start at GitHub: ShipCast, PushToPost and BuildCast. Each of those pages says when to buy the other one.

How Merge & Tell works