Six nouns, and the lines between them.
Products, repositories, ICPs, voices, social sets and the accounts you connect. They are six separate things on purpose, and the joins between them are the reason one merge can go out as two posts, written for two different readers, from two different identities.
The shape
Everything hangs off the repository
A merged pull request arrives at a repository, and the repository is where the app looks up everything else: what the change is to, who it is for, who is saying it, and which of your accounts hear about it. Four questions, four objects, resolved per pull request rather than baked into one setting.
Read it as a schema and you have it: one-to-many down the left, many-to-many where two things genuinely overlap, and exactly one hub. Nothing is duplicated across the diagram. A product’s one-liner is written once even if six repos ship it, and an account you connect is one row even if four sets point at it.
The objects
The six nouns
Product
The thing the posts are about: a name, a URL, a one-liner, a longer “what it is”, and who it is for. Account-level, not per repo.
has many repositories
Repository
A repo the GitHub App syncs in. Where merges arrive, and where the per-repo settings live: product, social set, allowed networks, posting mode, ICP, voices.
belongs to one product · names one set
ICP
The reader. “Platform engineers”, “recreational runners”, and their pains, goals, vocabulary and objections. The same diff reads differently for each.
many ↔ many with repositories
Voice
The speaker. A brand voice says “we shipped X”; a person voice says “I spent a day on X”. On LinkedIn only one of those is credible.
one per connected identity
Social set
A named group of connected accounts: “company”, “the side project”, “a client”. A repo targets the group instead of naming identities one at a time.
many ↔ many with accounts
Connected account
Your own X, Bluesky, Mastodon, LinkedIn or Discord destination. It posts as you, not through a middleman, and the credentials stay encrypted server-side.
in any number of sets · speaks in one voice
There is a seventh thing worth naming, and it is not a noun you configure: the channel. X, Bluesky, Mastodon, LinkedIn, Discord and your own changelog feed each have their own length limit and their own way of counting it, so a channel is a constraint on copy rather than a thing you file something under. The limits are on the features page.
The joins
Stated as cardinalities
You work with pull requests, so here it is in the terms you would use if you were drawing the tables yourself.
| Relationship | Shape | Where the link is made |
|---|---|---|
| Account → everything below | one-to-many | Every object carries its account. No query crosses that line. |
| Product → repositories | one-to-many | The Product field on the repo’s settings. |
| Repository → social set | many-to-one, and optional | The Social set field on the repo’s settings. A repo names at most one. |
| Social set ↔ connected accounts | many-to-many | Membership, ticked from either end: “who is in this set” or “which sets is this account in”. |
| Connected account → voice | many-to-one | The Posts as column on the accounts grid. This binding beats every other rule. |
| Repository ↔ ICPs | many-to-many | Linked from the ICP, so a repo can carry several audiences and an audience can span repos. |
| Repository ↔ voices | many-to-many | A default voice, plus a different speaker on any single channel. |
| Draft → the ICP it was written for | resolved per pull request | A hint in the PR body beats a path area, which beats the repo’s default. |
| Published post → product and set | many-to-one, stamped when queued | Recorded on the post, so “has this audience heard of this product lately” has something to read. |
Two rows in that table carry most of the weight. The many-to-many between repos and ICPs is what stops an audience from being a property of a codebase. The many-to-many between sets and accounts is what lets a single company LinkedIn belong to every product you ship without being copied six times.
One-to-many
One product is usually several repositories
Web, worker, infra, docs. Four repos, one thing your customers can buy. If the product were a field on the repo, you would be keeping four copies of the name, four copies of the URL, and four chances to describe the same app differently. That is the classic reason to normalise something out into its own table.
So a product is an account-level object that repos point at. Edit the one-liner once and every repo that ships it says the same sentence. It also carries what a pair of strings never could: a description worth introducing the product with.
Which is what makes the intro possible
A merged PR describes a change. It does not say what the change is to. That gap is invisible while you market one thing and opens the moment you market two. “Spent a day fighting Discord OAuth before realising a webhook URL was the whole answer” is a good post about nothing identifiable.
Whether a post introduces its product is a separate decision, resolved down a chain that ends at the speaker rather than the repo: a hint in the pull request, then the receiving account, then the voice, then the repo, then the product. If nobody has said, the default follows who is talking. A person voice introduces the product on first mention in a fortnight; a brand handle never does, because its bio already answered the question.
The tight channels get the one-liner and the roomy ones get the longer description, chosen from the channel’s own limit rather than a setting you have to keep in step with it.
Two many-to-manys
Audience and speaker are two axes, not one
This is the distinction most tools collapse, and it is worth being precise about: an ICP is who the copy is written for. A voice is who it is written by. “Written for platform engineers” says nothing about whether the sentence is “we shipped X” or “I spent a day on X”.
They compose: audience × speaker × channel → copy. Three axes, and every one of them is many-to-many with the repo, because none of them is a fact about the codebase.
Why you would want several ICPs
- One repo, two buyers. A change to the billing code is news for finance-minded operators; a change to the CLI is news for the developers. Same repo, different reader.
- Several products, several audiences. An ICP spans repos, so the ICP you wrote for one product applies to every repo that ships it without being retyped.
- The exception, per pull request. A hint in the PR body names the ICP for that merge only. The launch post and the week’s sixth bugfix do not have to agree.
Why you would want several voices
Because the credible sentence differs by identity, not by repo. The company account announces the feature; you, under your own name, announce the decision and let the feature be the evidence. Bind the voice to the account and no repo setting can make your company account speak as you, or vice versa.
That is also why voices are a fan-out axis rather than a style flag. If two identities on the same channel carry different voices, that channel gets two drafts, not one draft sent twice. And copy written as a person never auto-publishes, whatever the repo’s posting mode says. A first-person post is a claim about a human, so a human approves it.
The other many-to-many
Why sets overlap, and what filing an account does
A social set is a named group of connected accounts, and a repo targets the group rather than naming identities one at a time. The overlap is the point: acme/api and acme/web post as the company everywhere, your side project posts as you on Bluesky and Mastodon only, and the company LinkedIn belongs to both. Membership is many-to-many, so that account is one connection filed twice, not two connections to keep in step.
The resolution ladder
- The set the repo names, else
- your account’s default set, else
- the accounts that are in no set at all.
Then the repo’s network list narrows whatever that produced. The two gates are independent: the set decides which identities, the network list decides which of their networks. An account can clear the first and still be excluded by the second, and the app says so in those words rather than reporting a bare exclusion.
A set is a partition, not a filter you opt into
Rung 3 is the rule people are surprised by, and it is the one that protects you. Filing an account under a set takes it out of the pool that an unconfigured repo reaches. Connect a client’s X account, put it in their set, and that is on its own enough to keep every other repo off it. You do not have to go and edit the config of every repo you already had.
The account that belongs to everything
A partition has one shape it cannot express: the company account that genuinely announces every product. Ticking every set adds it to all three rungs: every named set, the default set and the unfiled pool. Sets you create next month include it with no further action. It writes no membership rows, so a set’s member list stays exactly what you ticked.
The failure mode matters more than the feature: a repo naming a set that no longer exists publishes nothing and tells you why. It never falls back to “everywhere”, not even to the every-set accounts. A rename must not quietly post your copy to accounts you excluded on purpose.
Because membership decides where copy lands, the picker prices the change before you make it: opening it says how many repos reach that account today, and each checkbox carries the delta it would cause (+2 repos, −3 repos). Sets segment your changelog feed too. Each set gets its own feed URL, stamped when the entry is queued, so re-filing a repo later cannot move published history between feeds.
At merge time
What the join looks like on a merge
None of this is resolved when you save a setting. It is resolved when a pull request merges, which is why changing a set re-routes the repos that name it immediately instead of the next time somebody re-saves them.
- A merge fires the webhook and lands on a repository. The filter ladder drops the noise before a token is spent: bots, lockfile churn, paths you marked
publish: never. - The repo resolves its product (what this is about) and its ICP (who it is for), the ICP by name: PR hint, then path area, then the repo’s default.
- Its social set resolves down the ladder above to a list of connected accounts, and the repo’s network list narrows that list.
- Each surviving account contributes the voice it is bound to, so the roster is one entry per (channel × voice). The voices this repo writes in are the voices its real targets carry.
- It generates one draft per roster entry, sized to that channel’s real limit, and creates one queued post per draft per receiving account, stamped with the product and set it went out under.
So the arithmetic is not “one merge, one post”. A repo pointed at a set holding your X, Bluesky and LinkedIn accounts, with the LinkedIn one bound to your personal voice and the other two to the company voice, produces company copy for X and Bluesky and a first-person post for LinkedIn, from one diff in one pass, with the LinkedIn one waiting for you because a person voice never auto-publishes.
The pipeline itself, what Claude reads and what it writes, is on the features page.
The argument
Why not just an API that sends tweets
Because “which network” is the least interesting decision in the whole job. Posting to X is a POST request; you could write it this afternoon. The questions that decide whether the post was worth publishing are these, and each of them is a different axis:
| Question | Answered by | Which is why it is its own object |
|---|---|---|
| What is this about? | Product | Several repos ship it; the answer must not be stored per repo. |
| Who is it for? | ICP | One repo can serve two buyers, and one buyer spans repos. |
| Who is saying it? | Voice | Bound to the identity, so no repo config can make your company account say “I”. |
| Who hears it? | Social set | Accounts overlap between products; a shared one is filed, not duplicated. |
| How long can it be? | Channel | Every network counts characters differently; it is a constraint, not a setting. |
| Does this reader know the product? | Intro policy | A fact about that audience, so it lives on the connection first. |
Collapse those into one list of channels and you get what most scheduling tools give you: the same sentence, six times, addressed to nobody in particular. Keep them separate and the same merge can reach a platform engineer as the company and a solo developer as you, on the channels each of those identities actually lives on, without anybody writing a second draft.
That is the whole reason for the extra nouns. You were going to merge the PR anyway. The model is what turns one merge into the several announcements it was worth.
That is the model.
The mechanism that runs on top of it is on the features page.