@recursiv/sdk 0.7.52 → 0.7.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -22,6 +22,38 @@ export interface DiscoveryLayout {
22
22
  theme?: string;
23
23
  windows?: DiscoveryLayoutWindow[];
24
24
  }
25
+ /**
26
+ * How to ask for the next page of the same feed: the next offset while the page order is a ranking,
27
+ * the cursor once the feed pages by keyset (a category feed past its window of newest judged posts), or null when the
28
+ * feed has no more. On a category feed, `fallover` then says where to go.
29
+ */
30
+ export type FeedNext = {
31
+ offset: number;
32
+ } | {
33
+ cursor: string;
34
+ } | null;
35
+ /**
36
+ * One step of a category feed's fall-through, in order: the same category's older posts (the keyset
37
+ * cursor; the same continuation `next` carries), the same category on the open networks, the member's
38
+ * next categories (`via`: the order they came from, the one `posts.categories()` serves), For you
39
+ * (signed in), All.
40
+ */
41
+ export type FalloverStep = {
42
+ kind: 'category_older';
43
+ category: string;
44
+ cursor: string;
45
+ } | {
46
+ kind: 'category_network';
47
+ category: string;
48
+ } | {
49
+ kind: 'category';
50
+ category: string;
51
+ via: 'personal' | 'popular' | 'static';
52
+ } | {
53
+ kind: 'for_you';
54
+ } | {
55
+ kind: 'all';
56
+ };
25
57
  export interface PaginationMeta {
26
58
  discovery?: DiscoveryLayout;
27
59
  scanned?: number;
@@ -36,6 +68,10 @@ export interface PaginationMeta {
36
68
  has_more: boolean;
37
69
  /** Pass this as `cursor` for the next page. Absent on offset-only endpoints. */
38
70
  next_cursor?: string | null;
71
+ /** The next page of this feed (offset or cursor), or null at its end. Absent on older servers. */
72
+ next?: FeedNext;
73
+ /** Present when a stage of a category feed ends: the ordered steps to follow so scrolling never stops. */
74
+ fallover?: FalloverStep[];
39
75
  }
40
76
  export interface PaginatedResponse<T> {
41
77
  data: T[];
@@ -99,6 +135,18 @@ export interface ViewerRelationship {
99
135
  /** This account follows the caller. */
100
136
  follows_you: boolean;
101
137
  }
138
+ /**
139
+ * The caller's relationship with one account (`profiles.isFollowing`): follows
140
+ * in both directions, and whether the caller blocks or mutes it. A server
141
+ * from before the moderation fields, or one whose read of them failed, leaves
142
+ * them out: absent means unknown, never "not blocked".
143
+ */
144
+ export interface ProfileRelationship extends ViewerRelationship {
145
+ /** The caller blocks this account. */
146
+ is_blocking?: boolean;
147
+ /** The caller mutes this account. */
148
+ is_muting?: boolean;
149
+ }
102
150
  export interface FollowUser extends ViewerRelationship {
103
151
  id: string;
104
152
  name: string;
@@ -123,9 +171,31 @@ export interface MediaItem {
123
171
  id: string;
124
172
  url: string;
125
173
  type: string;
174
+ /**
175
+ * Set on a legacy Minds video whose file is known to be gone (the server probes it): leave it out
176
+ * of Watch and clip queues and show the poster instead. Absent when the file plays or is unjudged.
177
+ */
178
+ unplayable?: boolean;
179
+ }
180
+ /** The group a post is in, as every post payload carries it. */
181
+ export interface PostCommunity {
182
+ id: string;
183
+ name: string;
184
+ slug: string;
185
+ /**
186
+ * A post in a group that is not public is for the group's members, and is
187
+ * never boosted: offer Boost only when this is 'public'.
188
+ */
189
+ privacy: 'public' | 'private' | 'hidden';
126
190
  }
127
191
  export interface Post {
192
+ /** Stored replies a reader can open. */
128
193
  reply_count?: number;
194
+ /**
195
+ * The comment count a legacy import brought with the post (minds.com). None of those comments are
196
+ * stored as replies, so it is not in `reply_count`.
197
+ */
198
+ reply_count_external?: number;
129
199
  id: string;
130
200
  /** Original Minds numeric GUID when this row came from the legacy import. */
131
201
  legacy_guid?: string | null;
@@ -133,6 +203,8 @@ export interface Post {
133
203
  content_format: 'plain' | 'markdown';
134
204
  author: UserSummary;
135
205
  community_id: string | null;
206
+ /** The group the post is in (null when it is in none). */
207
+ community?: PostCommunity | null;
136
208
  organization_id: string | null;
137
209
  reply_to_id?: string | null;
138
210
  /** Present when this post is an X-style repost — the original post is nested. */
@@ -154,10 +226,25 @@ export interface RepostedPost {
154
226
  content_format: 'plain' | 'markdown';
155
227
  author: UserSummary | null;
156
228
  community_id: string | null;
229
+ /** The group the original is in (null when it is in none): it decides what the repost card offers. */
230
+ community?: PostCommunity | null;
157
231
  organization_id: string | null;
158
232
  tags: Tag[];
159
233
  reactions_count: number;
160
234
  score: number;
235
+ /** The original's engagement, as the REST payload sends it for the original itself. */
236
+ upvote_count?: number;
237
+ downvote_count?: number;
238
+ /** The viewer's own reaction on the original ('upvote', 'downvote', ...), if any. */
239
+ user_reaction?: string | null;
240
+ /** Stored replies a reader can open. */
241
+ reply_count?: number;
242
+ /** A legacy import's comment count; none of those comments are stored as replies, so it is not in `reply_count`. */
243
+ reply_count_external?: number;
244
+ unique_commenters?: number;
245
+ reposts_count?: number;
246
+ /** Id of the viewer's own repost of the original; delete it to undo the repost. */
247
+ reposted_by_viewer_id?: string | null;
161
248
  media: MediaItem[];
162
249
  created_at: string;
163
250
  }
@@ -178,6 +265,8 @@ export interface PostReply {
178
265
  user_reaction?: string | null;
179
266
  /** This reply's own reply count, so its sub-thread can be opened. */
180
267
  reply_count?: number;
268
+ /** A legacy import's comment count for this reply; not stored as replies, so not in `reply_count`. */
269
+ reply_count_external?: number;
181
270
  created_at: string;
182
271
  }
183
272
  export interface ListPostRepliesParams {
@@ -243,6 +332,11 @@ export interface ListPostsParams extends PaginationParams {
243
332
  audience_user_id?: 'me' | string;
244
333
  /** Return only posts the authenticated caller has upvoted. */
245
334
  liked?: boolean;
335
+ /**
336
+ * Return only the caller's saved posts, newest saved first, each with `bookmarked_at`. Takes only `limit`
337
+ * (at most 50) and `cursor`; see `posts.bookmarked` / `bookmarks.posts`.
338
+ */
339
+ bookmarked?: boolean;
246
340
  }
247
341
  export interface SearchPostsParams extends PaginationParams {
248
342
  q: string;
@@ -251,6 +345,42 @@ export interface SearchPostsParams extends PaginationParams {
251
345
  sort?: 'relevance' | 'new';
252
346
  since?: string;
253
347
  until?: string;
348
+ /**
349
+ * The `meta.next_cursor` of the previous page. Later relevance pages are slices of the ranking the
350
+ * first page kept (ten minutes), so nothing is skipped or repeated while it lives; after that the page
351
+ * continues past the last post's score boundary. Newest-first pages continue by (created_at, id). A
352
+ * cursor is bound to its search and sort: another's is refused (400 `invalid_cursor`). Without one,
353
+ * `offset` counts.
354
+ */
355
+ cursor?: string;
356
+ }
357
+ /** A way on from the end of a search: newest first, the open networks, or a topic the matches share. */
358
+ export type SearchFallOverLane = {
359
+ kind: 'new';
360
+ } | {
361
+ kind: 'open_network';
362
+ } | {
363
+ kind: 'tag';
364
+ id: string;
365
+ name: string;
366
+ slug: string;
367
+ };
368
+ /** One page of `posts.search`. */
369
+ export interface SearchPostsResponse extends PaginatedResponse<Post> {
370
+ /**
371
+ * On the first relevance page: up to three of the newest matches that are not on the page, newest
372
+ * first, one per author, for a "Latest" strip above the best matches. Absent when there are none, and on later pages.
373
+ */
374
+ latest?: Post[];
375
+ /**
376
+ * On the last page (`meta.has_more` false): what exists past the end of this search, so a client
377
+ * can offer a way on instead of a wall. `new` when newest-first goes deeper than the ranking did;
378
+ * `open_network` when the caller's app can search the open networks; `tag` for the topic the
379
+ * page's posts share most. May be empty.
380
+ */
381
+ fall_over?: {
382
+ lanes: SearchFallOverLane[];
383
+ };
254
384
  }
255
385
  export type QueryParams = Record<string, unknown>;
256
386
  export interface CommunityCreator {
@@ -273,6 +403,8 @@ export interface Community {
273
403
  post_count?: number;
274
404
  /** Whether the caller is an accepted member. Returned by `list` and `search`. */
275
405
  is_member?: boolean;
406
+ /** The caller's role in the group, or null outside it. Returned by `list` and the detail read; a group's creator reads as its owner. */
407
+ viewer_role?: 'owner' | 'admin' | 'moderator' | 'member' | null;
276
408
  created_at: string;
277
409
  }
278
410
  /** Filters accepted by the communities directory. */
@@ -281,14 +413,36 @@ export interface ListCommunitiesParams extends PaginationParams {
281
413
  member?: boolean;
282
414
  /** Restrict personal keys to one organization. Project-bound keys are already scoped. */
283
415
  organization_id?: string;
284
- /** Server-side directory ordering. With `q`, name matches come first and this breaks ties. */
285
- sort?: 'members' | 'active' | 'newest';
416
+ /**
417
+ * Server-side ordering. With `q`, name matches come first and this breaks ties. `joined` (with
418
+ * `member: true`) lists the caller's groups by when they joined, newest first.
419
+ */
420
+ sort?: 'members' | 'active' | 'newest' | 'joined';
286
421
  /**
287
422
  * Case-insensitive text search over name and description, up to 200
288
423
  * characters. With `member: true` it searches only the caller's own groups.
289
424
  */
290
425
  q?: string;
291
426
  }
427
+ /** Which join requests to answer at once: named people (at most 100), or the oldest 100 waiting. */
428
+ export type AnswerJoinRequestsInput = {
429
+ user_ids: string[];
430
+ } | {
431
+ all: true;
432
+ };
433
+ /** What a many-at-once answer did: how many it answered, who it skipped, and how many still wait. */
434
+ export interface ApprovedJoinRequests {
435
+ approved: number;
436
+ /** Named people with no pending request here: already in, already answered, banned, or never asked. */
437
+ skipped: string[];
438
+ /** Pending requests left in the group (bans not counted). */
439
+ remaining: number;
440
+ }
441
+ export interface DeclinedJoinRequests {
442
+ declined: number;
443
+ skipped: string[];
444
+ remaining: number;
445
+ }
292
446
  /** The caller's own membership row in a group, if any. */
293
447
  export type CommunityMembershipStatus = 'accepted' | 'pending' | 'invited' | 'banned';
294
448
  export interface CommunityDetail extends Community {
@@ -862,6 +1016,14 @@ export interface Profile {
862
1016
  /** A row of a followers/following list: the profile plus the caller's relationship with it. */
863
1017
  export interface FollowListProfile extends Profile, ViewerRelationship {
864
1018
  }
1019
+ /** Paging and search for a follower or following list (profiles.followers / following). */
1020
+ export interface FollowListParams {
1021
+ limit?: number;
1022
+ offset?: number;
1023
+ /** Name or handle, matched anywhere in either (at most 200 characters); a blank term lists everyone. */
1024
+ search?: string;
1025
+ [key: string]: unknown;
1026
+ }
865
1027
  /** An account the caller blocked. */
866
1028
  export interface BlockedProfile extends Profile {
867
1029
  blocked_at: string;
@@ -901,10 +1063,24 @@ export interface UpdateProfileInput {
901
1063
  username?: string;
902
1064
  bio?: string | null;
903
1065
  image?: string | null;
1066
+ /** The profile banner as an absolute image URL, or null to remove it (PUT /profiles/me has accepted it since #1867). */
1067
+ banner?: string | null;
904
1068
  }
905
1069
  export interface SearchProfilesParams extends PaginationParams {
906
1070
  q: string;
907
1071
  }
1072
+ /** A row of `profiles.search`: the profile plus what the ranking read (match x ln(1 + followers + min(posts, 100))). */
1073
+ export interface ProfileSearchResult extends Profile {
1074
+ followers_count: number;
1075
+ posts_count: number;
1076
+ /** Nobody follows it and it never posted: listed after every account with a public life, for a client to fold under "more handles". */
1077
+ empty: boolean;
1078
+ /** An account the operator vouches for: draw the verified badge. */
1079
+ verified: boolean;
1080
+ plus: boolean;
1081
+ pro: boolean;
1082
+ founder: boolean;
1083
+ }
908
1084
  export interface SearchMentionsParams {
909
1085
  /** What the member typed after `@` (a leading `@` is ignored). */
910
1086
  q: string;
package/llm.md CHANGED
@@ -367,7 +367,9 @@ await r.auth.signOut(session.token)
367
367
  ```
368
368
 
369
369
  `getSession(token)` and `signOut(token)` send the explicit session token as
370
- Bearer auth, with a Cookie fallback for native/server runtimes.
370
+ Bearer auth, with a Cookie fallback for native/server runtimes. `getSession`
371
+ also answers `session: { id, expiresAt }`; an API-key client lists sessions with
372
+ `is_current: false`, so it matches `session.id` to mark its own device.
371
373
 
372
374
  Methods: `signUp`, `signIn`, `getSession`, `signOut`, `createApiKey`, `signInAndCreateKey`, `signUpAndCreateKey`, `verifyOtpAndCreateKey`
373
375
 
@@ -479,7 +481,7 @@ const { data: rows } = await r.profiles.followers('user_xxx') // each row: is_fo
479
481
  await r.profiles.mute('user_xxx') // server hides their posts and reposts of them from your feeds
480
482
  ```
481
483
 
482
- Methods: `list`, `me`, `get`, `getByUsername`, `search`, `mentions` (people + agents for an @-picker, follows first), `update`, `follow`, `unfollow`, `followers`, `following`, `followingIds` (your follow set as ids, cursor-paged), `isFollowing`, `related`, `leaderboard`, `block`, `unblock`, `blocked`, `mute`, `unmute`, `muted`
484
+ Methods: `list`, `me`, `get`, `getByUsername`, `search` (ranked by match x ln(1 + followers + min(posts, 100)) over one pool of 200, the same for every page; each row (`ProfileSearchResult`) carries `followers_count`, `posts_count`, `empty` (fold empties under "more handles") and `verified` (the accounts the operator names)), `mentions` (people + agents for an @-picker, follows first), `update`, `follow`, `unfollow`, `followers`, `following`, `followingIds` (your follow set as ids, cursor-paged), `isFollowing` (follows both ways, plus `is_blocking`/`is_muting` when the server knows), `related`, `leaderboard`, `block`, `unblock`, `blocked`, `mute`, `unmute`, `muted`
483
485
 
484
486
  ---
485
487
 
@@ -580,7 +582,36 @@ await r.posts.react(post.id, { type: 'fire' })
580
582
  const { data: results } = await r.posts.search({ query: 'shipped', community_id: 'comm_xxx' })
581
583
  ```
582
584
 
583
- Methods: `list`, `get`, `create`, `update`, `delete`, `search` (`sort: 'new'` for newest first), `replies(id, { limit, cursor })` (after `replies_next_cursor` on `get`), `react`, `unreact`
585
+ Methods: `list`, `get`, `create`, `update`, `delete`, `search`, `replies(id, { limit, cursor })` (after `replies_next_cursor` on `get`), `react`, `unreact`, `liked`, `bookmarked`, `bookmark`, `unbookmark`
586
+
587
+ `search` ranks relevance x recency x engagement (recency falls as a power of age: this month outranks years ago) through the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as `cursor`: later relevance pages are slices of the ranking the first page kept for ten minutes (nothing skipped or repeated while it lives; after that, the page continues past the last post's score boundary), newest-first pages continue by (created_at, id). A cursor is bound to its search and sort: another search's, or the other sort's, is 400 `invalid_cursor`. The first relevance page carries `latest` (up to three of the newest matches not on the page, one per author, for a Latest strip); the last page carries `fall_over.lanes` (`new`, `open_network`, `tag`): draw a way on, not a wall.
588
+
589
+ ### r.bookmarks — Synced saves (per member, per app)
590
+
591
+ ```typescript
592
+ await r.bookmarks.save({ kind: 'post', post_id: post.id }) // idempotent; a bare repost saves its original (see data.post_id)
593
+ await r.bookmarks.save({ kind: 'social', protocol: 'bluesky', native_id: 'at://did:plc:.../app.bsky.feed.post/...',
594
+ author: { display_name: 'Alice', handle: 'alice.bsky.social' }, snippet: 'hello' })
595
+ await r.bookmarks.save({ kind: 'article', url: 'https://news.example/a', title: 'Headline', outlet: 'Example' })
596
+ const { data: saved, meta } = await r.bookmarks.posts({ limit: 20 }) // = r.posts.bookmarked(); page on meta.next_cursor
597
+ const { data: items } = await r.bookmarks.list({ kind: 'social' }) // open-network posts and articles, as saved snapshots
598
+ const { data: keys } = await r.bookmarks.keys(undefined, { retry: false }) // every key, for a saved-state cache
599
+ const { data: merged } = await r.bookmarks.merge(deviceItems) // ≤200 per call, each with its own saved_at; idempotent
600
+ await r.bookmarks.unsave(key) // or unsave({ postId }); r.posts.bookmark/unbookmark too
601
+ ```
602
+
603
+ Newest saved first. Pages can be short while they skip posts the member can no longer read: page on `meta.next_cursor`, not length. Needs the member's own app key (else 403 `project_binding_required`). 503 `bookmarks_unavailable` means the server has no synced bookmarks yet: keep saves on the device (pass `{ retry: false }` to the probe so the 503 is not retried).
604
+
605
+ ### r.search
606
+
607
+ ```typescript
608
+ // One query, answered as sections. Each has its own time budget; a slow one is listed in `omitted`.
609
+ const { data: answer } = await r.search.query({ q: 'apple', limit: 6 })
610
+ for (const section of answer.order) render(section, answer.sections[section])
611
+ // top_posts, latest_posts, open_network, people, groups, topics, stories, related, trending
612
+ ```
613
+
614
+ `trending` joins when the answer is thin or old, so a search never dead-ends. `top_posts` is floored as `r.posts.search`'s relevance page is; `people` rows carry `followers_count`, `posts_count`, `empty` and `verified` (see `r.profiles.search`). Page further with `r.posts.search`. Needs `posts:read`; `people` needs `users:read`, `groups` `communities:read`; `open_network` and `stories` need `users:read` plus the caller's own app key on its network, `trending` that app key. What the key cannot have is in `omitted` (`scope` / `unavailable`).
584
615
 
585
616
  ### r.communities
586
617
 
@@ -649,7 +680,7 @@ Methods: `list`, `get`, `create`, `delete`
649
680
  - `r.billing` — Usage tracking, checkout sessions, portal sessions, metered billing
650
681
  - `r.appSubscriptions` — Project-scoped consumer plans: `checkout` (409 `subscription_exists` when the member already pays), `status`, `createPortalSession({ return_url, flow?: 'cancel' })` (`flow: 'cancel'` opens Stripe's cancel confirmation; 409 `no_subscription` when there is no plan to cancel), `previewTierChange({ tier })`, `changeTier({ tier, proration_date? })` (in-place, prorated, never a second subscription)
651
682
  - `r.boosts` — Promoted posts (micro-USD): `create`, `mine`, `balance`, `pause`, `resume`, `cancel`, `recordEvent`; buy credit with `topup({ amount_cents, return_url })` then `confirmTopup(sessionId)` (credits once); admin `listPendingReview`, `review`, `grant`
652
- - `r.notifications` — The notification stream: `list({ category: 'social' })` (rows carry `actor`, `reaction_type` and a `post_preview` with the viewer's `user_reaction`/`reposted_by_viewer_id` and counts), `unreadCount({ category })` for badges (`const { data } = await r.notifications.unreadCount({ category: 'social' })`), `markAsRead(id)`, `archive(id)` to dismiss, `markAllAsRead(category)` or `markAllAsRead({ category, organization_id })`, plus push token `registerToken`/`unregisterToken`. A push's `data.notificationId` is the row to mark read on tap. DMs are not in the stream.
683
+ - `r.notifications` — The notification stream: `list({ category: 'social' })` (rows carry `actor`, `reaction_type` and a `post_preview` with the viewer's `user_reaction`/`reposted_by_viewer_id` and counts; an `agent_needs_input` row carries `answered`, true once you have written in the agent's thread since it asked, when its title reads "You answered <agent>"), `unreadCount({ category })` for badges (`const { data } = await r.notifications.unreadCount({ category: 'social' })`), `markAsRead(id)`, `archive(id)` to dismiss, `markAllAsRead(category)` or `markAllAsRead({ category, organization_id })`, plus push token `registerToken`/`unregisterToken`. A push's `data.notificationId` is the row to mark read on tap. DMs are not in the stream.
653
684
  - `r.uploads` — Legacy media upload URLs (prefer `r.storage` for new projects)
654
685
  - `r.github` — GitHub integration (getRepository, getTree, getFileContent, getCommits, createFromTemplate)
655
686
  - `r.deployments` — Deployment management (prefer `r.projects.deploy()` for new projects)
@@ -679,7 +710,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
679
710
  - `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
680
711
  - Native Nostr: `const { data: preview } = await r.protocols.prepareNativeNostr(identityId, { action: "publish", content: draft })`; review and sign the exact public event, then `submitNativeNostr(identityId, { intent: preview.intent, event, approve: true })`. Preparation/submission never auto-retry. After uncertainty, use `getNativeNostrPost(identityId, preview.postId ?? preview.eventId)` before a user-selected retry of the identical intent/event: deletion checks the original note's `postId`, publication checks `eventId`. Check `nativeNostrAccount()` enablement first; keys stay with the signer. For replies, after `nativeNostrAccount()` advertises `reply_text_note`, use `prepareNativeNostr(identityId, { action: "reply", content: draft, parentId: post.nativeId })`. Review returned parent/root/participants/relay and exact event, sign with the connected client signer, then `submitNativeNostr(identityId, { intent, event, approve: true })`. Requires users:read, posts:read and posts:write. Kind1 public notes only; reply enablement and shared UI/live proof remain pending. Do not inherit arbitrary notification tags or relay hints. Signing mutations never auto-retry; check `getNativeNostrPost(identityId, eventId)` after uncertainty before a user-selected identical-event retry.
681
712
  - Hosted Bluesky account on the app's own PDS: `const { data } = await r.protocols.createHostedIdentity({ protocol: 'atproto', handle: 'alice', email })`. Only the label is sent; `data.handle` carries the domain and `data.password` is returned this once, never stored. Never auto-retried. Either the account is finished or it is removed again. 409 `handle_unavailable` = choose another label; 503 `handle_dns_unavailable`/`handle_unresolvable`/`handle_unverified` = operator setup, retry later unchanged.
682
- - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows.
713
+ - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`.
683
714
  - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor? })`; render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
684
715
  - `listCandidates({ protocol?, limit?, cursor? })` and `getCandidate(id)` are read-only quarantine review, not published-post search. Both require `admin` scope, a project-bound key, a live admin role, and existing project-admin access. The key supplies app/network scope; neither method accepts another project or network. They never invoke refresh, change settings, or publish.
685
716
  - Lists return `{ data: ProtocolQuarantinedCandidate[], meta: { limit, has_more, next_cursor } }`; use the opaque `next_cursor`, not offset pagination. The API defaults `limit` to 20 and permits at most 50. Details return `{ data: ProtocolQuarantinedCandidate }`. Every candidate has `state: 'quarantined'` and `content_format: 'plain'`; render its bounded `content` as text, respect `content_truncated`, and distinguish upstream `published_at` from local `observed_at` / `created_at`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.52",
3
+ "version": "0.7.54",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",