The Community Manager Pattern

@brittanyellich.com recently shared a post about her work on “opensocial.community” and the challenges of representing groups in ATProtocol. She focuses on the infrastructure layer, like group management, authentication, and storing shared resources. I want to add to her ideas by looking at the "community manager" pattern. This pattern lets communities act as full identities while keeping the data ownership that makes ATProtocol unique.

The Core Problem

Before looking at solutions, it's important to remember an important rule: in ATProtocol, each identity can only write records to its own repository. This rule is central to the protocol and ensures real data ownership and portability.

In the diagram @ngerakines.me can add a book review to their own repository on https://pds.cauda.cloud, but not directly to @dayton-readers.club's repository. This setup protects data ownership, but it also raises a key question: how can communities work if members can't add content directly to shared spaces?

The IETF 124 Discussion

At IETF 124 in Montreal earlier this year, @bmann.ca organized a multi-day ATProtocol meetup. We had a good group, including representation from Bluesky Social PBC, Stream Place, Leaflet, Cosmik Network, Germ, me, and several others in the community. One topic we discussed was how shared-identity and shared-data public communities could work in ATProtocol.

This conversation built on earlier talks I had with @tom.sherman.is while he was working through communities at https://frontpage.fyi . We kept coming back to an idea: communities don’t need to store member content themselves. They just need to reference it.

More at https://atprotocommunity.leaflet.pub/3m5pejic4fk2p

Communities as Identities

We treat communities as full ATProtocol identities. For example, @dayton-readers.club has its own DID and repository (PDS), just like any other identity. The main difference is what gets stored in that repository.

A community’s repository serves three distinct purposes:

1. Community Profile and Properties

The community has its own profile records in the format needed for the AppViews it uses. If a book club wants to appear in Bluesky, it would have an app.bsky.actor.profile record. If it's mainly a book-focused community, it might have records showing which books are being read, meeting schedules, and community guidelines.

2. Membership Attestations

This is where things get interesting. Individual members maintain their own "membership card" records in their own repositories. The community then stores attestations that acknowledge these memberships.

Let's look at a concrete example. If I want to join @dayton-readers.club, I first create a membership record in my own repository:

{
"uri": "at://ngerakines.me/community.opensocial.membership/1",
"cid": "bafyreif3e4fsrxox4vskuemnbmij2hb6e3pxxoujwd6jcncey7iw7dfrzi",
"value": {
"$type": "community.opensocial.membership",
"community": "did:plc:dayton-readers-club",
"role": "member",
"since": "2025-12-21T14:41:02.431Z"
}
}

This record lives in my PDS, and I own it. It shows my intent to join the community, my role, and when I joined. But by itself, it doesn't prove the community has accepted me. Anyone could write a membership record and claim to be part of any community.

The community completes the handshake by creating a membership proof record in its repository:

{
"uri": "at://dayton-readers.club/community.opensocial.membershipProof/1",
"cid": "bafyreigbhl46xyeabrdrxzlb3weio2xmdkszrgey44p7anrwaqndaczrse",
"value": {
"$type": "community.opensocial.membershipProof",
"cid": "bafyreigdshj27haq2qvwoogpzlniehyzreusaxcgjmhmkksm6witmkobxa"
},
"signatureRecord": {
"$type": "community.opensocial.membership",
"community": "did:plc:dayton-readers-club",
"role": "member",
"since": "2025-12-21T14:41:02.431Z",
"$sig": {
"$type": "community.opensocial.membershipProof",
"repository": "ngerakines.me"
}
}
}

The membership proof's only contains a CID, which is a SHA-256 hash of the DAG-CBOR encoded membership record. The signatureRecord shown here isn't actually stored in the record, just as the uri and cid fields are metadata from the getRecord XRPC method. I included it to show that the value.cid field comes from my membership record content and a signature block that identifies the source repository.

This design has several important properties:

Privacy through indirection: If you look only at the community's repository, you'll see a list of CIDs. You can't tell who the members are unless you also fetch the matching membership records from each member's repository. This makes it harder for someone to casually list all members.

User-controlled revocation: If I want to leave the community, I just delete my membership record. The community's proof will then point to content that no longer exists. I don't need anyone's permission; I simply remove my side of the relationship. Users have the right to be forgotten and this model supports that.

Community-controlled acceptance: The community decides which memberships to confirm. Just creating a membership record doesn't make you a member; the community also needs to create the matching proof.

Verifiable without trust: Anyone can check a membership by making sure the CID in the community's proof matches the hash of the user's membership record. No trusted third party is needed.

3. Community Data and Wrapped Content

This is the AppView-specific data that makes each community unique. For example, a book club might post about reading selections or meeting events, while a technical community might have wiki pages and member-submitted content.

The Wrapper Pattern

ATProtocol's philosophy is that users should own their data. But communities also need some control over content that appears for or with the community. The wrapper pattern solves this by using ATProtocol's strongRef primitive.

When a user wants to post a book review to their community, there are two steps:

Step 1: The user writes their content to their own PDS

{
"uri": "at://ngerakines.me/app.book-clubs.bookReview/1",
"cid": "bafyreias5wkc5ln337adu6yrefvwoidn4ueajnpulvaaqat72jiufew234",
"value": {
"$type": "app.book-clubs.bookReview",
"title": "1984",
"text": "Kind of dark."
}
}

The user owns this record. It lives in their repository, on their PDS. They are the author and can delete or change it whenever they want.

Step 2: The community creates a wrapper record

{
"uri": "at://dayton-readers.club/app.book-clubs.communityBookReview/1",
"cid": "bafyreiajawyv6dyx4qinlttposk4eyeldtufy43fodla7qhgokp5rm7xii",
"value": {
"$type": "app.book-clubs.communityBookReview",
"categories": [
"classics"
],
"series": "2025-12-01 2025-12-31",
"review": {
"uri": "at://ngerakines.me/app.book-clubs.bookReview/1",
"cid": "bafyreias5wkc5ln337adu6yrefvwoidn4ueajnpulvaaqat72jiufew234"
}
}
}

The wrapper lives in the community's repository and contains a strongRef pointing to the user’s original content. It can also include community-specific metadata, like categorization and which reading period the review belongs to.

Separating Concerns

This pattern achieves an important goal: it separates authorship from distribution.

The user is clearly the author of their book review, and the cryptographic chain of custody is easy to follow. But the community decides if the review appears in the community setting.

Consider the moderation implications:

Community removes offensive content: The community deletes the wrapper record. The review is no longer visible in the community's AppView, but the user's original content stays in their repository. The user's data is not removed.

User decides to leave: The user can delete their original content. The community’s wrapper now points to a record that no longer exists. AppViews can handle this by showing the wrapper without the content or hiding it completely.

Cross-posting: A user can have their review show up in several communities. Each community creates its own wrapper that points to the same original content. The user writes the review once and it appears in different places.

This approach offers the best of both worlds. Communities get the moderation tools they need to keep spaces healthy, and users keep full ownership of their content. This is the ATProtocol Ethos.

The Community Manager Service

So, who creates these wrapper records and membership proofs? This is where a "community manager" service comes in. It connects Brittany's "opensocial.community" work with this pattern.

A community manager is an ATProtocol service that provides XRPC methods to manage community access, permissions, and data. In this example, the did:web:opensocial.community service identity provides several important methods:

community.opensocial.register - Registers a community with the community manager service. Registration involves some setup steps we’ll walk through below.

community.opensocial.setAppPassword - Sets the app-password used for community PDS actions.

community.opensocial.acceptMember - Takes an AT-URI and CID of a membership record, creates a membership proof record in the community's PDS, and returns a strong ref to be used as a remote attestation in the membership record.

com.atproto.repo.putRecord - Writes a record to the community's PDS on behalf of authorized members.

This could be a centralized service like "opensocial.community" or communities can self-host if they want full control. The pattern works in both cases because the data structures and XRPC interfaces stay the same. Only the way they are run changes.

Let's walk through the complete lifecycle using @dayton-readers.club as an example, with Brittany and Nick.

Step 0: Creating the Community Identity

Brittany (did:plc:brittany) wants to create the "dayton-readers.club" community. She starts by creating a DID for the community (did:plc:dayton-readers-club) and setting up its handle. This is standard ATProtocol identity setup. There's nothing community-specific yet.

Step 1: Registering with the Community Manager

Brittany navigates to the community manager implementation at "opensocial.community" and authenticates via OAuth as the @dayton-readers.club identity. She submits the "manage this identity as a community" form, specifying did:plc:brittany as the initial admin.

This kicks off the registration process and creates several follow-up actions:

Admin invitation: The service creates an invitation for Brittany to accept. This is necessary because Brittany will need an attested community.opensocial.membership record to manage the community through the "opensocial.community" AppView.

App-password prompt: Brittany enters an app-password that the community manager will use for future PDS write operations for members.

DID document update: The service tells Brittany to add a "CommunityManager" service entry to the @dayton-readers.club DID document. This is how other services know which community manager is the main one for this community.

Step 2: Completing Admin Setup

Brittany adds the app-password to the community manager, logs out, then logs back in as did:plc:brittany to accept the admin invitation. Accepting writes the membership record to her own PDS.

At this point, two records exist:

at://did:plc:brittany/community.opensocial.membership/1

at://did:plc:dayton-readers-club/community.opensocial.membershipProof/1

Step 3: Creating the Book Club

Brittany navigates to the book.club AppView at https://book.club/ and authenticates as did:plc:brittany. She creates a book club for the community.

The book.club AppView is community-manager-aware. It checks the @dayton-readers.club DID document, finds the "CommunityManager" service, and makes sure Brittany has a membership record with {"role": "admin"} that's been attested by the community.

Once permissions are checked, Brittany uses the book.club AppView to write the community's profile. Behind the scenes, book.club sends a service-authenticated request (using inter-service JWT authentication ) to the community manager's com.atproto.repo.putRecord endpoint. This creates the at://did:plc:dayton-readers-club/club.book.profile/self record.

When the community manager gets this call, it checks that the DID in the service-auth token (did:plc:brittany) has permission to write the record. The exact permission rules depend on the implementation, but for now, assume the caller needs a valid attested membership record.

Three records now exist:

at://did:plc:brittany/community.opensocial.membership/1

at://did:plc:dayton-readers-club/community.opensocial.membershipProof/1

at://did:plc:dayton-readers-club/club.book.profile/self

Step 4: Inviting Members

Brittany loves reading, but the point of a book club is to read with others. She invites Nick to join through the book.club AppView.

Nick authenticates as did:plc:nick, goes to the community profile, and clicks "Join". This writes a membership record to Nick's PDS, but without any attestation yet.

Brittany gets a notification that Nick's membership is pending. She logs into book.club and approves it. The approval flow has book.club make a service-auth call to community.opensocial.acceptMember, which writes a proof record to the community’s PDS and returns a signature for updating Nick's membership record.

Five records now exist:

at://did:plc:brittany/community.opensocial.membership/1

at://did:plc:dayton-readers-club/community.opensocial.membershipProof/1

at://did:plc:dayton-readers-club/club.book.profile/self

at://did:plc:nick/community.opensocial.membership/1

at://did:plc:dayton-readers-club/community.opensocial.membershipProof/2

Step 5: Contributing Content

Fast forward: the community has posted an app.bsky.feed.post promoting the December book selection, "You Deserve a Tech Union". Nick has read it and wants to share a review.

Nick uses the book.club AppView to write his review. The review record goes to his PDS. While writing, Nick indicates he wants the review to be part of @dayton-readers.club.

When the review is published, book.club makes another service-auth call to the community manager's com.atproto.repo.putRecord endpoint. This creates a wrapper record in the community's repository that references Nick's review. Nick has permission because he's an attested member.

Eight records now exist:

at://did:plc:brittany/community.opensocial.membership/1

at://did:plc:dayton-readers-club/community.opensocial.membershipProof/1

at://did:plc:dayton-readers-club/club.book.profile/self

at://did:plc:nick/community.opensocial.membership/1

at://did:plc:dayton-readers-club/community.opensocial.membershipProof/2

at://did:plc:dayton-readers-club/app.bsky.feed.post/1

at://did:plc:nick/club.book.review/1

at://did:plc:dayton-readers-club/club.book.communityReview/1

Nick owns his review. The community owns the wrapper. If the review violates community guidelines, moderators can delete the wrapper without touching Nick's original content. If Nick leaves the community, his review stays in his repository. Only the community's reference to it disappears.

Whats Next

I'm excited to see where Brittany's work on opensocial.community goes. I think these patterns will keep developing as more people build community-focused applications on ATProtocol.

The types, record structures, and lexicon methods aren't finalized and will likely change as the user experience is built out. There is no one-size-fits-all solution, but I believe this brings us closer to a flexible approach where communities and their members have power and control over their identities and data.