···11+# EffemKit
22+33+Swift client for the Effem AT Protocol AppView. Provides social podcasting features — subscriptions, comments, recommendations, bookmarks, curated lists, and profiles — built on the AT Protocol.
44+55+## Requirements
66+77+- Swift 6.2+
88+- iOS 26+ / macOS 26+ / watchOS 26+ / tvOS 26+
99+1010+## Dependencies
1111+1212+- [CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol) — AT Protocol primitives, OAuth, networking
1313+1414+## Installation
1515+1616+Add EffemKit as a dependency in your `Package.swift`:
1717+1818+```swift
1919+dependencies: [
2020+ .package(url: "https://tangled.org/@sparrowtek.com/EffemKit", branch: "main"),
2121+]
2222+```
2323+2424+Or add it as a local package in Xcode.
2525+2626+## Quick Start
2727+2828+### 1. Configure the AppView
2929+3030+```swift
3131+import EffemKit
3232+import CoreATProtocol
3333+3434+// At app launch
3535+Task { @APActor in
3636+ setup(appViewHost: "https://appview.effem.xyz")
3737+}
3838+```
3939+4040+### 2. Read Data
4141+4242+```swift
4343+@APActor
4444+func loadTrending() async throws -> [PodcastResult] {
4545+ let service = EffemService()
4646+ let response = try await service.getTrending(max: 20)
4747+ return response.feeds
4848+}
4949+```
5050+5151+### 3. Write Data (requires authentication)
5252+5353+```swift
5454+@APActor
5555+func subscribe(feedId: Int, userDID: String) async throws {
5656+ let repoService = EffemRepoService()
5757+ let podcast = PodcastRef(feedId: feedId)
5858+ _ = try await repoService.subscribe(to: podcast, repo: userDID)
5959+}
6060+```
6161+6262+## Architecture
6363+6464+EffemKit uses a two-router architecture:
6565+6666+| Path | Service | Target | Auth Required |
6767+|------|---------|--------|--------------|
6868+| Read | `EffemService` | Effem AppView | No |
6969+| Write | `EffemRepoService` | User's PDS | Yes |
7070+7171+**Reads** go to the Effem AppView, which indexes social records from the AT Protocol firehose and proxies Podcast Index metadata enriched with social overlay data.
7272+7373+**Writes** go to the authenticated user's PDS using standard `com.atproto.repo.createRecord` / `deleteRecord` calls. The AppView picks up new records asynchronously via the firehose.
7474+7575+All public API is isolated to `@APActor` for thread safety.
7676+7777+## API Overview
7878+7979+### EffemService (Read)
8080+8181+| Category | Methods |
8282+|----------|---------|
8383+| Subscriptions | `getSubscriptions`, `getSubscribers` |
8484+| Comments | `getComments`, `getCommentThread` |
8585+| Recommendations | `getRecommendations`, `getPopular` |
8686+| Lists | `getList`, `getLists` |
8787+| Bookmarks | `getBookmarks` |
8888+| Inbox | `getInbox` |
8989+| Profiles | `getProfile` |
9090+| Podcast Search | `searchPodcasts`, `searchEpisodes` |
9191+| Podcast Metadata | `getPodcast`, `getEpisodes`, `getEpisode`, `getTrending`, `getCategories` |
9292+9393+### EffemRepoService (Write)
9494+9595+| Category | Methods |
9696+|----------|---------|
9797+| Subscriptions | `subscribe`, `unsubscribe` |
9898+| Comments | `postComment`, `deleteComment` |
9999+| Recommendations | `recommend`, `unrecommend` |
100100+| Bookmarks | `bookmark`, `removeBookmark` |
101101+| Lists | `createList`, `deleteList` |
102102+| Profile | `updateProfile` |
103103+104104+## Lexicon Namespace
105105+106106+All Effem records use the `xyz.effem.*` namespace:
107107+108108+- `xyz.effem.feed.subscription`
109109+- `xyz.effem.feed.comment`
110110+- `xyz.effem.feed.recommendation`
111111+- `xyz.effem.feed.bookmark`
112112+- `xyz.effem.feed.list`
113113+- `xyz.effem.actor.profile`
114114+115115+## Documentation
116116+117117+Build the DocC documentation:
118118+119119+```bash
120120+swift package generate-documentation
121121+```
122122+123123+Or in Xcode: Product > Build Documentation.
124124+125125+## Testing
126126+127127+```bash
128128+swift test
129129+```
130130+131131+15 tests across 8 suites covering model decoding, round-tripping, serialization, and AT URI generation.
+81
Sources/EffemKit/EffemKit.docc/EffemKit.md
···11+# ``EffemKit``
22+33+Swift client for the Effem AT Protocol AppView — social podcasting on the AT Protocol.
44+55+## Overview
66+77+EffemKit provides an iOS client for interacting with the Effem AppView, a custom AT Protocol service that adds social features to podcast listening. The package handles two distinct communication paths:
88+99+- **Read path** — ``EffemService`` queries the Effem AppView for social data (comments, subscriptions, recommendations) and Podcast Index metadata enriched with social overlays.
1010+- **Write path** — ``EffemRepoService`` writes records to the user's AT Protocol PDS (Personal Data Server), which the AppView then indexes asynchronously via the AT Protocol firehose.
1111+1212+All public API is isolated to the `@APActor` global actor from CoreATProtocol, providing thread-safe access without manual synchronization.
1313+1414+## Topics
1515+1616+### Essentials
1717+1818+- <doc:GettingStarted>
1919+- ``setup(appViewHost:)``
2020+- ``EffemEnvironment``
2121+2222+### Reading Data
2323+2424+- <doc:ReadingData>
2525+- ``EffemService``
2626+2727+### Writing Data
2828+2929+- <doc:WritingData>
3030+- ``EffemRepoService``
3131+- ``CommentReplyRef``
3232+3333+### Models — AT Protocol Records
3434+3535+- ``Subscription``
3636+- ``Comment``
3737+- ``CommentAuthor``
3838+- ``Recommendation``
3939+- ``Bookmark``
4040+- ``PodcastList``
4141+- ``EffemProfile``
4242+4343+### Models — Podcast Index
4444+4545+- ``PodcastRef``
4646+- ``EpisodeRef``
4747+- ``PodcastResult``
4848+- ``EpisodeResult``
4949+- ``PodcastCategory``
5050+- ``SocialOverlay``
5151+- ``InboxItem``
5252+5353+### Models — Responses
5454+5555+- ``SubscriptionsResponse``
5656+- ``SubscribersResponse``
5757+- ``SubscriberInfo``
5858+- ``CommentsResponse``
5959+- ``CommentThreadResponse``
6060+- ``CommentThreadNode``
6161+- ``RecommendationsResponse``
6262+- ``PopularResponse``
6363+- ``PopularEpisode``
6464+- ``ListsResponse``
6565+- ``ListResponse``
6666+- ``BookmarksResponse``
6767+- ``ProfileResponse``
6868+- ``PodcastSearchResponse``
6969+- ``PodcastDetailResponse``
7070+- ``EpisodeSearchResponse``
7171+- ``EpisodesResponse``
7272+- ``EpisodeDetailResponse``
7373+- ``TrendingResponse``
7474+- ``CategoriesResponse``
7575+- ``InboxResponse``
7676+- ``CreateRecordResponse``
7777+7878+### Errors
7979+8080+- ``EffemKitConfigurationError``
8181+- ``EffemRepoError``
+134
Sources/EffemKit/EffemKit.docc/GettingStarted.md
···11+# Getting Started with EffemKit
22+33+Configure EffemKit and make your first API calls.
44+55+## Overview
66+77+EffemKit requires two pieces of configuration before use:
88+99+1. **AppView host** — The URL of your Effem AppView server (for reading social and podcast data).
1010+2. **PDS authentication** — An authenticated CoreATProtocol session (for writing records to the user's repository).
1111+1212+Read-only features (browsing podcasts, viewing comments) only need the AppView host. Write features (subscribing, commenting, recommending) additionally require PDS authentication.
1313+1414+## Configure the AppView
1515+1616+Call ``setup(appViewHost:)`` once at app launch, typically in your `App` initializer or an early scene phase handler:
1717+1818+```swift
1919+import EffemKit
2020+import CoreATProtocol
2121+2222+@main
2323+struct EffemApp: App {
2424+ init() {
2525+ Task { @APActor in
2626+ setup(appViewHost: "https://appview.effem.xyz")
2727+ }
2828+ }
2929+3030+ var body: some Scene {
3131+ WindowGroup {
3232+ ContentView()
3333+ }
3434+ }
3535+}
3636+```
3737+3838+> Important: All EffemKit API is isolated to `@APActor`. Call EffemKit methods from within an `@APActor`-isolated context or use `Task { @APActor in ... }`.
3939+4040+## Authenticate the User
4141+4242+EffemKit relies on CoreATProtocol for OAuth authentication. Once the user signs in through CoreATProtocol, the PDS host is automatically available and ``EffemRepoService`` can write records:
4343+4444+```swift
4545+// After CoreATProtocol OAuth flow completes,
4646+// APEnvironment.current.host is set to the user's PDS.
4747+// No additional EffemKit configuration is needed for writes.
4848+```
4949+5050+## Read Data with EffemService
5151+5252+``EffemService`` provides all read-only queries. Create an instance and call any method:
5353+5454+```swift
5555+@APActor
5656+func loadTrending() async throws -> [PodcastResult] {
5757+ let service = EffemService()
5858+ let response = try await service.getTrending(max: 20)
5959+ return response.feeds
6060+}
6161+```
6262+6363+```swift
6464+@APActor
6565+func loadComments(feedId: Int, episodeId: Int) async throws -> [Comment] {
6666+ let service = EffemService()
6767+ let response = try await service.getComments(
6868+ feedId: feedId,
6969+ episodeId: episodeId
7070+ )
7171+ return response.comments
7272+}
7373+```
7474+7575+## Write Data with EffemRepoService
7676+7777+``EffemRepoService`` writes records to the user's PDS. The `repo` parameter is the user's DID:
7878+7979+```swift
8080+@APActor
8181+func subscribeToPodcast(feedId: Int, userDID: String) async throws {
8282+ let repoService = EffemRepoService()
8383+ let podcast = PodcastRef(feedId: feedId)
8484+ let response = try await repoService.subscribe(to: podcast, repo: userDID)
8585+ // response.uri contains the AT URI of the created record
8686+ // response.cid contains the content hash
8787+}
8888+```
8989+9090+## Handle Pagination
9191+9292+Most list endpoints support cursor-based pagination:
9393+9494+```swift
9595+@APActor
9696+func loadAllSubscriptions(did: String) async throws -> [Subscription] {
9797+ let service = EffemService()
9898+ var allSubscriptions: [Subscription] = []
9999+ var cursor: String?
100100+101101+ repeat {
102102+ let response = try await service.getSubscriptions(
103103+ did: did,
104104+ cursor: cursor,
105105+ limit: 50
106106+ )
107107+ allSubscriptions.append(contentsOf: response.subscriptions)
108108+ cursor = response.cursor
109109+ } while cursor != nil
110110+111111+ return allSubscriptions
112112+}
113113+```
114114+115115+## Error Handling
116116+117117+EffemKit throws ``EffemKitConfigurationError`` when the AppView or PDS isn't configured:
118118+119119+```swift
120120+@APActor
121121+func safeFetch() async {
122122+ let service = EffemService()
123123+ do {
124124+ let trending = try await service.getTrending()
125125+ // use trending.feeds
126126+ } catch let error as EffemKitConfigurationError {
127127+ // Handle missing configuration
128128+ print(error.localizedDescription)
129129+ } catch {
130130+ // Handle network or decoding errors
131131+ print(error.localizedDescription)
132132+ }
133133+}
134134+```
+182
Sources/EffemKit/EffemKit.docc/ReadingData.md
···11+# Reading Data from the AppView
22+33+Query social data, podcast metadata, and search results through the Effem AppView.
44+55+## Overview
66+77+``EffemService`` is the single entry point for all read operations. Every method maps to an XRPC endpoint on the Effem AppView, returning strongly-typed response models.
88+99+The AppView serves two categories of data:
1010+1111+- **Social data** — Records written by users (subscriptions, comments, recommendations, bookmarks, lists) that the AppView indexed from the AT Protocol firehose.
1212+- **Podcast metadata** — Podcast Index data proxied and cached by the AppView, enriched with ``SocialOverlay`` counts.
1313+1414+## Discover Podcasts
1515+1616+Search for podcasts and episodes, browse trending content, or look up specific metadata:
1717+1818+```swift
1919+@APActor
2020+func discoverPodcasts() async throws {
2121+ let service = EffemService()
2222+2323+ // Search
2424+ let searchResults = try await service.searchPodcasts(query: "swift")
2525+ for podcast in searchResults.feeds {
2626+ print("\(podcast.title ?? "Untitled") — \(podcast.social?.subscriberCount ?? 0) subscribers")
2727+ }
2828+2929+ // Trending
3030+ let trending = try await service.getTrending(max: 10, lang: "en")
3131+3232+ // By category
3333+ let categories = try await service.getCategories()
3434+3535+ // Podcast detail with social overlay
3636+ let detail = try await service.getPodcast(feedId: 75075)
3737+ print("Subscribers: \(detail.feed.social?.subscriberCount ?? 0)")
3838+}
3939+```
4040+4141+## Browse Episodes
4242+4343+```swift
4444+@APActor
4545+func browseEpisodes(feedId: Int) async throws {
4646+ let service = EffemService()
4747+4848+ // List episodes for a podcast
4949+ let episodes = try await service.getEpisodes(feedId: feedId, max: 20)
5050+5151+ // Single episode detail
5252+ if let first = episodes.items.first {
5353+ let detail = try await service.getEpisode(episodeId: first.episodeId)
5454+ }
5555+5656+ // Search episodes
5757+ let results = try await service.searchEpisodes(query: "WWDC")
5858+}
5959+```
6060+6161+## Social Features
6262+6363+### Subscriptions
6464+6565+```swift
6666+@APActor
6767+func viewSubscriptions(userDID: String) async throws {
6868+ let service = EffemService()
6969+7070+ // A user's subscriptions
7171+ let subs = try await service.getSubscriptions(did: userDID)
7272+ for sub in subs.subscriptions {
7373+ print("Subscribed to feed \(sub.podcast.feedId) on \(sub.createdAt)")
7474+ }
7575+7676+ // Who subscribes to a specific podcast
7777+ let subscribers = try await service.getSubscribers(feedId: 75075)
7878+ for subscriber in subscribers.subscribers {
7979+ print("\(subscriber.displayName ?? subscriber.did)")
8080+ }
8181+}
8282+```
8383+8484+### Comments
8585+8686+```swift
8787+@APActor
8888+func viewComments(feedId: Int, episodeId: Int) async throws {
8989+ let service = EffemService()
9090+9191+ // Flat list of comments for an episode
9292+ let comments = try await service.getComments(
9393+ feedId: feedId,
9494+ episodeId: episodeId
9595+ )
9696+9797+ // Threaded view of a specific comment
9898+ if let first = comments.comments.first {
9999+ let thread = try await service.getCommentThread(uri: first.uri)
100100+ // thread.thread.comment is the root
101101+ // thread.thread.replies contains nested replies
102102+ }
103103+}
104104+```
105105+106106+### Recommendations and Popular Episodes
107107+108108+```swift
109109+@APActor
110110+func viewRecommendations(feedId: Int, episodeId: Int) async throws {
111111+ let service = EffemService()
112112+113113+ // Recommendations for a specific episode
114114+ let recs = try await service.getRecommendations(
115115+ feedId: feedId,
116116+ episodeId: episodeId
117117+ )
118118+119119+ // Most recommended episodes this week
120120+ let popular = try await service.getPopular(period: "week", limit: 10)
121121+ for ep in popular.episodes {
122122+ print("\(ep.title ?? "Untitled") — \(ep.recommendationCount) recs")
123123+ }
124124+}
125125+```
126126+127127+### Bookmarks, Lists, and Inbox
128128+129129+```swift
130130+@APActor
131131+func viewUserContent(userDID: String) async throws {
132132+ let service = EffemService()
133133+134134+ // Bookmarks (optionally filtered by podcast or episode)
135135+ let bookmarks = try await service.getBookmarks(did: userDID)
136136+137137+ // Curated podcast lists
138138+ let lists = try await service.getLists(did: userDID)
139139+ if let first = lists.lists.first {
140140+ let detail = try await service.getList(uri: first.uri)
141141+ }
142142+143143+ // New episodes from subscribed podcasts
144144+ let inbox = try await service.getInbox(did: userDID)
145145+ for item in inbox.items {
146146+ print("New: \(item.episode.title ?? "Untitled") from \(item.podcastTitle ?? "Unknown")")
147147+ }
148148+}
149149+```
150150+151151+### Profiles
152152+153153+```swift
154154+@APActor
155155+func viewProfile(userDID: String) async throws {
156156+ let service = EffemService()
157157+ let profile = try await service.getProfile(did: userDID)
158158+ print("""
159159+ \(profile.profile.displayName ?? profile.profile.did)
160160+ Subscriptions: \(profile.profile.subscriptionCount ?? 0)
161161+ Comments: \(profile.profile.commentCount ?? 0)
162162+ Genres: \(profile.profile.favoriteGenres?.joined(separator: ", ") ?? "none")
163163+ """)
164164+}
165165+```
166166+167167+## Social Overlay
168168+169169+Many podcast and episode responses include a ``SocialOverlay`` with engagement counts. This data is computed by the AppView from indexed AT Protocol records:
170170+171171+```swift
172172+if let social = podcastResult.social {
173173+ // How many Effem users subscribe to this podcast
174174+ let subscribers = social.subscriberCount ?? 0
175175+176176+ // How many comments exist across all episodes
177177+ let comments = social.commentCount ?? 0
178178+179179+ // Which of the current user's follows also subscribe
180180+ let followingWhoSubscribe = social.subscribedByFollowing ?? []
181181+}
182182+```
+208
Sources/EffemKit/EffemKit.docc/WritingData.md
···11+# Writing Data to the AT Protocol
22+33+Create and delete records in the user's AT Protocol repository.
44+55+## Overview
66+77+``EffemRepoService`` writes Effem records to the authenticated user's PDS (Personal Data Server) using standard AT Protocol repository operations (`com.atproto.repo.createRecord` and `com.atproto.repo.deleteRecord`).
88+99+Records are written to the user's PDS, not the AppView. The AppView discovers new records asynchronously by subscribing to the AT Protocol firehose. There may be a brief delay (typically under a second) between writing a record and it appearing in AppView query results.
1010+1111+> Important: The user must be authenticated via CoreATProtocol before calling any ``EffemRepoService`` method. If the PDS host isn't configured, methods throw ``EffemKitConfigurationError/pdsHostNotConfigured``.
1212+1313+## Subscribe to a Podcast
1414+1515+```swift
1616+@APActor
1717+func subscribe(feedId: Int, feedUrl: String?, userDID: String) async throws {
1818+ let repoService = EffemRepoService()
1919+ let podcast = PodcastRef(feedId: feedId, feedUrl: feedUrl)
2020+2121+ let response = try await repoService.subscribe(to: podcast, repo: userDID)
2222+ // response.uri = "at://did:plc:xxx/xyz.effem.feed.subscription/3jm2..."
2323+ // response.cid = content hash of the record
2424+2525+ // Extract the rkey from the URI for later deletion
2626+ let rkey = response.uri.split(separator: "/").last.map(String.init) ?? ""
2727+}
2828+```
2929+3030+To unsubscribe, pass the record key from the original subscription:
3131+3232+```swift
3333+@APActor
3434+func unsubscribe(rkey: String, userDID: String) async throws {
3535+ let repoService = EffemRepoService()
3636+ try await repoService.unsubscribe(rkey: rkey, repo: userDID)
3737+}
3838+```
3939+4040+## Post a Comment
4141+4242+Comments are attached to a specific episode. You can optionally include a playback timestamp and a reply reference for threading:
4343+4444+```swift
4545+@APActor
4646+func postComment(
4747+ feedId: Int,
4848+ episodeId: Int,
4949+ text: String,
5050+ timestamp: Int?,
5151+ userDID: String
5252+) async throws {
5353+ let repoService = EffemRepoService()
5454+ let episode = EpisodeRef(feedId: feedId, episodeId: episodeId)
5555+5656+ let response = try await repoService.postComment(
5757+ episode: episode,
5858+ text: text,
5959+ timestamp: timestamp,
6060+ repo: userDID
6161+ )
6262+}
6363+```
6464+6565+### Reply to a Comment
6666+6767+To reply to an existing comment, construct a ``CommentReplyRef`` with the root and parent comment URIs and CIDs:
6868+6969+```swift
7070+@APActor
7171+func replyToComment(
7272+ episode: EpisodeRef,
7373+ text: String,
7474+ parentComment: Comment,
7575+ rootComment: Comment,
7676+ rootCID: String,
7777+ parentCID: String,
7878+ userDID: String
7979+) async throws {
8080+ let repoService = EffemRepoService()
8181+8282+ let reply = CommentReplyRef(
8383+ rootURI: rootComment.uri,
8484+ rootCID: rootCID,
8585+ parentURI: parentComment.uri,
8686+ parentCID: parentCID
8787+ )
8888+8989+ let response = try await repoService.postComment(
9090+ episode: episode,
9191+ text: text,
9292+ reply: reply,
9393+ repo: userDID
9494+ )
9595+}
9696+```
9797+9898+## Recommend an Episode
9999+100100+```swift
101101+@APActor
102102+func recommend(feedId: Int, episodeId: Int, text: String?, userDID: String) async throws {
103103+ let repoService = EffemRepoService()
104104+ let episode = EpisodeRef(feedId: feedId, episodeId: episodeId)
105105+106106+ let response = try await repoService.recommend(
107107+ episode: episode,
108108+ text: text,
109109+ repo: userDID
110110+ )
111111+}
112112+```
113113+114114+## Bookmark an Episode
115115+116116+Bookmarks can include a playback timestamp so the user can return to a specific moment:
117117+118118+```swift
119119+@APActor
120120+func bookmarkCurrentPosition(
121121+ feedId: Int,
122122+ episodeId: Int,
123123+ playbackSeconds: Int,
124124+ userDID: String
125125+) async throws {
126126+ let repoService = EffemRepoService()
127127+ let episode = EpisodeRef(feedId: feedId, episodeId: episodeId)
128128+129129+ let response = try await repoService.bookmark(
130130+ episode: episode,
131131+ timestamp: playbackSeconds,
132132+ repo: userDID
133133+ )
134134+}
135135+```
136136+137137+## Create a Podcast List
138138+139139+Curated lists group multiple podcasts under a name and optional description:
140140+141141+```swift
142142+@APActor
143143+func createList(userDID: String) async throws {
144144+ let repoService = EffemRepoService()
145145+146146+ let podcasts = [
147147+ PodcastRef(feedId: 75075, feedUrl: "https://example.com/feed.xml"),
148148+ PodcastRef(feedId: 920666),
149149+ ]
150150+151151+ let response = try await repoService.createList(
152152+ name: "My Favorite Tech Pods",
153153+ description: "The best podcasts about Swift and iOS development",
154154+ podcasts: podcasts,
155155+ repo: userDID
156156+ )
157157+}
158158+```
159159+160160+## Update User Profile
161161+162162+The Effem profile is stored as a single record with the fixed key `"self"`. Calling ``EffemRepoService/updateProfile(displayName:description:favoriteGenres:repo:)`` creates or overwrites it:
163163+164164+```swift
165165+@APActor
166166+func updateProfile(userDID: String) async throws {
167167+ let repoService = EffemRepoService()
168168+169169+ let response = try await repoService.updateProfile(
170170+ displayName: "Alice",
171171+ description: "Podcast enthusiast and Swift developer",
172172+ favoriteGenres: ["Technology", "Science", "Comedy"],
173173+ repo: userDID
174174+ )
175175+}
176176+```
177177+178178+## Delete Any Record
179179+180180+Every write method that creates a record has a corresponding delete method. All delete methods take an `rkey` (record key) and the user's DID:
181181+182182+```swift
183183+@APActor
184184+func deleteOperations(userDID: String) async throws {
185185+ let repoService = EffemRepoService()
186186+187187+ try await repoService.unsubscribe(rkey: "3jm2abc", repo: userDID)
188188+ try await repoService.deleteComment(rkey: "3jm2def", repo: userDID)
189189+ try await repoService.unrecommend(rkey: "3jm2ghi", repo: userDID)
190190+ try await repoService.removeBookmark(rkey: "3jm2jkl", repo: userDID)
191191+ try await repoService.deleteList(rkey: "3jm2mno", repo: userDID)
192192+}
193193+```
194194+195195+## Understanding Record Keys
196196+197197+When you create a record, the PDS assigns a TID (timestamp-based identifier) as the record key. The ``CreateRecordResponse`` contains the full AT URI:
198198+199199+```
200200+at://did:plc:abc123/xyz.effem.feed.subscription/3jm2szx5c47mo
201201+│ │ │ │
202202+│ │ │ └─ rkey (record key)
203203+│ │ └─ collection (lexicon ID)
204204+│ └─ repo (user's DID)
205205+└─ AT URI scheme
206206+```
207207+208208+Extract the `rkey` from the URI's last path component to use with delete methods. Store record keys locally (e.g., in SwiftData) so you can delete records later without querying the AppView.