Bluesky starter packs look like a single thing in the app — a shareable page that lets a new user follow a curated group in one tap. At the protocol level they are three separate records glued together by references, and if you create them from code (we do, as part of an automated outreach pipeline), the decomposition matters: it decides what you can update later, what you can only create once, and where a naive script will quietly make a mess.
The three records
Everything below is plain com.atproto.repo.createRecord / putRecord calls against your own PDS — no special API surface.
1. The list — app.bsky.graph.list. A starter pack is backed by an ordinary Bluesky list with purpose: app.bsky.graph.defs#referencelist. The list record itself holds metadata — name, purpose, createdAt, plus optional description and avatar. Members live elsewhere.
2. The memberships — app.bsky.graph.listitem. One record per member, each holding the member's DID and the list's AT-URI. There is no "add 20 members" batch call in the record layer: twenty members means twenty listitem creates. Plan for partial failure in the middle of that loop — more below.
3. The pack — app.bsky.graph.starterpack. The record that makes the share page exist. Per the lexicon, name, list, and createdAt are required; list is the AT-URI of the referencelist from step 1, the name is capped at 50 graphemes, and optional feeds can attach custom feeds. The official limits: up to 150 people, up to 3 feeds.
The share URL is derivable, not returned: https://bsky.app/starter-pack/{your-handle}/{rkey} where {rkey} is the tail of the starterpack record's AT-URI.
The trap: creation is not idempotent
Every createRecord mints a fresh rkey. Run your create-starter-pack script twice and you have two packs with two URLs, both live, both indexed — and the one you already shared is not the one your script now reports. There is no natural key (like a title) that the protocol dedupes on.
Our rules after learning this:
- Creation is a one-time, human-confirmed operation. The script that creates a pack is separate from everything that maintains it, and we never wire creation into any automated loop.
-
Growth goes through a dedicated add-members path that takes the existing list's AT-URI, fetches current members via
app.bsky.graph.getList(paginate with the cursor — you'll miss members beyond the first page otherwise), dedupes the incoming batch against both the fetched members and itself, slices to remaining capacity against the 150 cap, and only then loops over listitem creates. - Partial failure is reported, not raised. If member 14 of 24 fails, thirteen listitems already exist; throwing away the result tells the caller a lie ("nothing happened"). We return added-so-far plus the error, and the next run's dedupe makes the retry safe.
Updating pack metadata is the one place you get real idempotency: putRecord with the same rkey overwrites in place. Name changes, description edits, feed swaps — same URL throughout. It is only the create that bites.
Things that surprised us
- We enforce the 150-person cap ourselves. It is a product-level limit, and we have never pushed a list past it to find out what the record layer does — slicing to remaining capacity in our own code is cheaper than discovering the boundary in production.
- Deleting the pack record kills the share page but leaves the list and every listitem in your repo. Cleanup means walking all three layers in reverse.
- Membership edits propagate to an already-shared pack, because the pack references the list rather than snapshotting it. This is the feature that makes packs worth automating: the URL you shared last month gets better as your curation does.
Nothing here needs more than an authenticated agent and the three record types — which is exactly why it's worth doing from code: curation quality, not API access, becomes the only bottleneck.
We run this pipeline while building Rulestack — rule and skill packs for AI coding agents.
The packs themselves live at @ai-shop.bsky.social if you want to see the output.
Top comments (0)