Adding groups to an existing app
What changes when an app that already has users and data takes on groups, using Grain's group photo pools as the worked example.
The other guides show the building blocks. This one covers the decisions that come from adding groups to an app people already use. The example throughout is Grain, a photo app whose groups each have a members-only pool of galleries.
1. Give your content a space type of your own
Decide what a group’s members share in your app, and make that a space type under your own namespace. Grain’s pool is social.grain.group:
{
"lexicon": 1,
"id": "social.grain.group",
"defs": {
"main": {
"type": "space",
"key": "literal:self",
"name": "Group pool",
"collections": [
"social.grain.gallery", "social.grain.gallery.item", "social.grain.photo",
"social.grain.favorite", "social.grain.comment"
]
}
}
}
- Keep your existing record types. A gallery in a pool is the same
social.grain.galleryas a public one. Only where it’s written changes, so your rendering code carries over. - Publish the space type at your lexicon authority (the DID in your
_lexicon.<your domain>DNS record), like any lexicon you own. A PDS resolves the space type when someone asks for a scope on it. If it can’t, sign-in fails. - The group creates the space once, with
createSpaceandreadableBy: ["member"]. After that, membership is the permission. Your app needs no membership check of its own before a write, because the member’s PDS asks the group’s host.
2. Members write their own records into it
A member posts into the pool from their own session, into their own repo inside the group’s space. Grain writes a gallery, its photos and its items as one commit:
await session.fetchHandler("/xrpc/com.atproto.space.applyWrites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
space: `at://${group}/space/social.grain.group/self`,
repo: session.did,
writes: [
{ $type: "com.atproto.space.applyWrites#create", collection: "social.grain.gallery", rkey, value: gallery },
// …photos and gallery items
],
}),
});
Upload blobs through the ordinary blob endpoint first, as for any post.
Leaving takes their content with them. The records are the member’s, so when they leave, their galleries leave the pool, and nothing needs deleting. Grain says so plainly in its UI: “Your galleries leave its pool with you.”
3. Read with the viewer’s own credential
Space writes never reach a firehose, so there’s nothing to index. Read a pool on demand, the way every space is read: a credential for the viewer, listRepos on the group’s host, then each member’s records from their PDS.
- A non-member’s read is refused. That refusal is the members-only feature, so show a “members only” state, not an error.
- Cache the credential briefly (Grain: 4 minutes), and clear it when the viewer leaves the group.
- Tell “no spaces” apart from “not a member”. A user whose PDS doesn’t support spaces can’t read any pool. Tell them their account can’t do this yet, instead of asking them to join.
4. Ask for the scopes, carefully
Grain adds these to its existing scope:
space:social.grain.group?authority=*&skey=*&collection=social.grain.gallery&…&action=read&action=create&action=update&action=delete
space:social.grain.group?authority=*&skey=*&action=read
space:fyi.opensocial.members?authority=*&skey=*&collection=fyi.opensocial.acceptance&action=read&action=create&action=update&action=delete
rpc:fyi.opensocial.requestJoin?aud=*
rpc:fyi.opensocial.leaveGroup?aud=*
(Plus rpc: entries for provisionGroup and createSpace if the app starts groups.)
- A separate read-only scope on your space type (the second line). A session signed in as a group is narrowed to what the person’s role allows. If reading and writing share one scope, losing the write loses the read, and the group can’t open its own pool.
- Only ask where it can work. Grain requests group and pool scopes only from PDSes that serve
com.atproto.space.getDelegationToken, and hides “Join” from users who couldn’t open a pool. - Declare every scope you might request in your client metadata. Some PDSes reject the whole authorization with
invalid_scopeif a requested scope isn’t declared there. - Existing users sign in again. Their sessions were granted the old scope. The first group call fails with
ScopeMissingError; send them back through sign-in, with copy that says why (“Sign in again to open this gallery”).
5. Show groups where your app shows people
A group DID can appear anywhere an account can: as an author, in search, on a profile URL.
- Detect it by indexing
fyi.opensocial.declarationfrom the firehose alongside your own collections. A DID with a declaration is a group. - Show it with its group profile. Grain uses its own profile record if the group has written one, falling back to the host’s
listGroupsentry (cached for ten minutes). - Don’t show a member count or roster. Who belongs isn’t public.
- When the viewer is the group (signed in as it), skip membership checks, hide Join and Leave, and fetch the profile fresh. A steward who just edited it on the host expects to see the change.
6. Membership and joining
Grain follows Finding groups and Calling group methods:
- Which groups am I in:
listSpaces?type=fyi.opensocial.memberson the user’s PDS for candidates, each confirmed with a members-space credential. Cache a refusal briefly, and bypass the cache on the group’s own page. - Join:
requestJoin. If it returnsadmitted, write the member’sacceptancestraight away. If it returnspending, write it the first time you see them admitted. - Leave: delete the acceptance first, while the space still accepts their writes, then call
leaveGroup, then clear your cached membership and credential.
7. Starting a group without leaving your app
An app can create a group for a user and get its own session on it, so the founder never sees a second sign-in. On the reference host this is fyi.opensocial.provisionGroup, which isn’t part of the standard yet.
- Get on the host’s list. A host issues group sessions only to apps it provisions for. On host.opensocial.fyi that’s an allowlist of OAuth client IDs; ask the operator. Your app must be a confidential client, able to sign a client assertion.
- Founder’s service auth:
getServiceAuthon the user’s PDS withlxm=fyi.opensocial.provisionGroup. - Call the host as an OAuth client:
POST https://host.opensocial.fyi/xrpc/fyi.opensocial.provisionGroup
Authorization: Bearer <founder's service auth>
DPoP: <proof signed by the key the session will be bound to>
Content-Type: application/json
{
"handle": "coffee-riders",
"displayName": "Coffee Riders",
"scope": "atproto repo:social.grain.actor.profile",
"client_id": "https://your.app/oauth-client-metadata.json",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "<JWT signed with your client key, audience = the host>"
}
scope must fall within the scopes your client metadata declares. The response has did, meta, members, and session: a standard OAuth token response, bound to your DPoP key and issued to your client ID. Store it like any session and refresh it at the host’s token endpoint. Retry once on use_dpop_nonce. Other errors: UntrustedApp, InvalidScope, HandleTaken.
4. Create your space for the group with the founder’s createSpace call, and write anything the group itself should author (Grain writes the group’s profile) with the new session.
5. Wait for the declaration. It reaches your index through the firehose moments after provisioning. Grain polls briefly so the group’s page exists when the founder lands on it.
Handles are short: a PDS takes 3 to 18 characters for the first label. Validate before calling, and log the host’s error instead of showing it.
8. Acting as the group
People who can act for the group sign in to your app with the group’s handle (see Writing as the group). If your app already has an account switcher, that’s all the UI it needs.
- Expect a narrowed session. The host grants only what the person’s role allows. Treat a scope that was withheld as “you can’t do that as this group”, not as a broken session. Don’t delete the session and force a new sign-in.
- One session per account. If your app stores sessions by DID, a person signing in as a group replaces any session your app got from
provisionGroupfor that group, and the other way round.
9. Words on the screen
Say what people can do, not how it works. Nobody using a photo app needs to hear “space”, “credential”, “PDS” or “scope”. Grain’s copy:
| Situation | Copy |
|---|---|
| A non-member opens a pool | “Only members can see this pool. If you’ve just joined, reload the page.” |
| Signed out | “This pool is the club’s, not the network’s. Sign in as a member to see it.” |
| The user’s account can’t use pools | “Your account is hosted somewhere that doesn’t support private sharing yet.” |
| Starting a group | “A group has its own account and a pool its members post galleries into. You’ll be its first admin.” |
| Join states | “You’re in” · “Request sent to the moderators” · “Invite only” |
| The host refused a new group | “That handle is taken or not allowed. Try another one.” |
Checklist
- A space type under your namespace, published at your lexicon authority
- Members write their own records into it; nothing to delete when they leave
- Reads use the viewer’s credential; “not a member” and “no spaces” are distinct states
- Scopes declared in client metadata, a separate read-only scope, requested only where spaces work
- Existing users are sent back through sign-in, with a reason
- Groups detected from their declaration and shown with their group profile
- Join, leave and “my groups” per the guides
- Optional: start groups from your app with
provisionGroup - No protocol words on screen