@recursiv/sdk 0.7.70 → 0.7.72

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
@@ -32,6 +32,19 @@ export type FeedNext = {
32
32
  } | {
33
33
  cursor: string;
34
34
  } | null;
35
+ /**
36
+ * How many outlets reported a story (0.7.71): the distinct outlets of the articles it lists, by lane. An allow-listed
37
+ * outlet is one outlet however many hosts it publishes on, in its lane; an article from anywhere else (a Hacker News
38
+ * story's link) is one outlet per host, in `other`. `mainstream + independent + other` is always `total`. The radar
39
+ * list, a radar story read, the News category section, World Today, the top of a feed, search and For you serve the
40
+ * same object for one story, so it is the one number to show; a story's `sources.outlet` counts articles, not outlets.
41
+ */
42
+ export interface StoryOutlets {
43
+ total: number;
44
+ mainstream: number;
45
+ independent: number;
46
+ other: number;
47
+ }
35
48
  /**
36
49
  * One step of a category feed's fall-through, in order: the same category's older posts (the keyset
37
50
  * cursor; the same continuation `next` carries), the same category on the open networks, the member's
@@ -91,6 +104,13 @@ export interface CompassFilterMeta {
91
104
  o: number;
92
105
  };
93
106
  };
107
+ /**
108
+ * `flags` (0.7.72): Hide politics was applied by the floor every caller gets, with no compass judgment: a story's
109
+ * `political` flag and theme (the radar, World Today), a network post's cached browse category (trends, the viral
110
+ * read), a Minds post's browse category (Watch's videos, a visitor's Discover posts). Sent where the operator does not
111
+ * serve this caller the full filter yet. Absent: the full filter as served. Choose sides and Mix sides never take it.
112
+ */
113
+ basis?: 'flags';
94
114
  }
95
115
  export interface PaginationMeta {
96
116
  discovery?: DiscoveryLayout;
@@ -261,9 +281,22 @@ export interface Post {
261
281
  media: MediaItem[];
262
282
  /** Set when this post is pinned in its profile or community container feed. */
263
283
  pinned_at?: string | null;
284
+ /**
285
+ * A paid placement (`curator.forYou`): always show it labelled Boosted, and report its impression
286
+ * with `boosts.recordEvent(boost_campaign_id, ...)`. Absent on an organic post.
287
+ */
288
+ boosted?: boolean;
289
+ boost_campaign_id?: string;
290
+ /** Who paid for a boosted slot. Absent on an organic post, and from a server before it said. */
291
+ boost_disclosure?: BoostDisclosure;
264
292
  created_at: string;
265
293
  updated_at: string;
266
294
  }
295
+ /** Who paid for a boosted slot, read from its campaign (a boosted repost's byline is the original author's). */
296
+ export interface BoostDisclosure {
297
+ /** The paying member's handle; null when it could not be read or the account has none. */
298
+ payer_username: string | null;
299
+ }
267
300
  export interface RepostedPost {
268
301
  id: string;
269
302
  /** Original Minds numeric GUID when this row came from the legacy import. */
@@ -1427,11 +1460,32 @@ export interface RegisterTokenInput {
1427
1460
  */
1428
1461
  device_key?: string;
1429
1462
  }
1463
+ /** The three ways one kind of notification can reach someone. */
1464
+ export interface NotificationChannelToggles {
1465
+ inApp: boolean;
1466
+ push: boolean;
1467
+ email: boolean;
1468
+ }
1469
+ /**
1470
+ * The kinds of notification a person can set one by one. Social: mentions,
1471
+ * replies, reposts, reactions, follows and messages. Operator: agent work,
1472
+ * deploys, billing, invites and the daily email. A server may send more keys
1473
+ * than these.
1474
+ */
1475
+ export type NotificationChannelKind = 'post_mention' | 'post_reply' | 'post_repost' | 'post_reaction' | 'follow' | 'message' | 'agent_activity' | 'billing' | 'community_invite' | 'project_invite' | 'deployment' | 'daily_digest';
1476
+ /** Every kind with all three channels spelled out, as the delivery gates read them. */
1477
+ export type NotificationChannelSettings = Partial<Record<NotificationChannelKind, NotificationChannelToggles>> & Record<string, NotificationChannelToggles>;
1430
1478
  export interface UserPreferences {
1431
1479
  notifications: {
1480
+ /** Master switches: on when any kind has the channel on. */
1432
1481
  in_app: boolean;
1433
1482
  push: boolean;
1434
1483
  email: boolean;
1484
+ /**
1485
+ * Each kind on its own (`post_reaction: { inApp: true, push: false, email: false }`).
1486
+ * Absent on servers older than SDK 0.7.72.
1487
+ */
1488
+ channels?: NotificationChannelSettings;
1435
1489
  };
1436
1490
  privacy: {
1437
1491
  profile_visibility: 'public' | 'network' | 'private';
@@ -1453,9 +1507,18 @@ export interface ContentPreferences {
1453
1507
  default_feed: 'foryou' | 'following';
1454
1508
  }
1455
1509
  export interface UpdateNotificationChannelsInput {
1510
+ /** Master switch: sets in-app on every kind. */
1456
1511
  in_app?: boolean;
1512
+ /** Master switch: sets push on every kind. */
1457
1513
  push?: boolean;
1514
+ /** Master switch: sets email on every kind. */
1458
1515
  email?: boolean;
1516
+ /**
1517
+ * Set single kinds and leave every other kind as it is:
1518
+ * `{ post_reaction: { push: false } }` stops reaction pushes and nothing else.
1519
+ * Applied after the master switches when both are sent.
1520
+ */
1521
+ channels?: Partial<Record<NotificationChannelKind, Partial<NotificationChannelToggles>>> & Record<string, Partial<NotificationChannelToggles>>;
1459
1522
  }
1460
1523
  export interface UpdatePrivacyInput {
1461
1524
  profile_visibility?: 'public' | 'network' | 'private';
@@ -1521,6 +1584,10 @@ export interface RevokeOtherSessionsResult {
1521
1584
  /** Whether keep_session_token named a session of this account, which was kept. */
1522
1585
  kept_session: boolean;
1523
1586
  }
1587
+ /** Confirms deactivation (`settings.disableAccount`) with the account's password. */
1588
+ export interface DisableAccountInput {
1589
+ password: string;
1590
+ }
1524
1591
  export interface DeletionStatus {
1525
1592
  requested: boolean;
1526
1593
  scheduled_at: string | null;
package/llm.md CHANGED
@@ -27,6 +27,7 @@ const r = new Recursiv() // reads RECURSIV_API_KEY from env
27
27
  | Transactional email (receipts, alerts) | `r.email.send()` | Direct Resend/SendGrid calls |
28
28
  | Receive external webhooks (Stripe, etc.) | `r.webhooks.register()` | Custom webhook server |
29
29
  | Server-side cron / scheduled tasks | `r.jobs.create()` | External cron services |
30
+ | A member's engagement signals (views, dwell, hides) | `r.signals.batch()` | Raw fetch to `/signals/*`, or a resource's `client` |
30
31
 
31
32
  **IMPORTANT:** Do NOT store structured app data (records, inventory, tasks, settings) as JSON inside `r.posts.content`. Posts are for social content. Use `r.databases` for application data.
32
33
 
@@ -157,6 +158,11 @@ const { content, conversationId } = await r.agents.chatStreamText(agent.id, {
157
158
  conversation_id: existingConvId, // optional
158
159
  })
159
160
 
161
+ // The app's agent directory, searched and sorted on the server (0.7.71). Keyless works for visitors:
162
+ // new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }) on the app's API host (public profiles only).
163
+ const { data: found, meta } = await r.agents.discover({ q: 'space', sort: 'popular', limit: 20 }) // sort: popular | recent | name
164
+ // found[]: { id, name, username, image, bio, follower_count, verified, created_at }; next page: discover({ ..., cursor: meta.next_cursor })
165
+
160
166
  // Give agent access to project infrastructure
161
167
  await r.agents.grantProjectAccess(agent.id, {
162
168
  project_id: project.id,
@@ -164,7 +170,7 @@ await r.agents.grantProjectAccess(agent.id, {
164
170
  })
165
171
  ```
166
172
 
167
- Methods: `list`, `get`, `create`, `update`, `delete`, `chat`, `chatStream`, `chatStreamText`, `retryChatStream`, `retryChatStreamText` ("Try again": `{ conversation_id, retry_message_id }` re-answers your latest message without sending it twice), `conversations`, `listDiscoverable`, `leaderboard`, `grantProjectAccess`, `revokeProjectAccess`, `resetRequestCount`, `getByClaimToken`, `claim`, `orchestratorStatus`
173
+ Methods: `list`, `get`, `create`, `update`, `delete`, `chat`, `chatStream`, `chatStreamText`, `retryChatStream`, `retryChatStreamText` ("Try again": `{ conversation_id, retry_message_id }` re-answers your latest message without sending it twice), `conversations`, `discover` (search and sort the app's published agents, cursor paged, keyless-readable), `listDiscoverable`, `leaderboard`, `grantProjectAccess`, `revokeProjectAccess`, `resetRequestCount`, `getByClaimToken`, `claim`, `orchestratorStatus`
168
174
 
169
175
  ### r.brain — AI assistant messaging
170
176
 
@@ -695,7 +701,7 @@ for (const section of answer.order) render(section, answer.sections[section])
695
701
  // top_posts, latest_posts, open_network, people, groups, topics, stories, related, trending
696
702
  ```
697
703
 
698
- `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`).
704
+ `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`); `groups` rows carry `description` (one line) and `last_active_at`, to tell groups of one name apart. 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`).
699
705
 
700
706
  ### r.curator — World Today and its story pages
701
707
 
@@ -714,7 +720,7 @@ for await (const p of r.curator.storyPostPages({ storyKey: page.story_key }, { r
714
720
  ```typescript
715
721
  const { data: c } = await r.communities.create({ name: 'Denver Devs', slug: 'denver-devs', privacy: 'public' })
716
722
  await r.communities.join(c.id)
717
- const { data: found } = await r.communities.search('denver', { limit: 10 }) // name matches first
723
+ const { data: found } = await r.communities.search('denver', { limit: 10 }) // name matches first, then size; no 1-member group unless named exactly or yours
718
724
  const { data: mine } = await r.communities.search('book', { member: true }) // only my groups
719
725
  const { data: people } = await r.communities.members(c.id, { q: 'ada' }) // name or username
720
726
  ```
@@ -782,11 +788,17 @@ Methods: `list`, `get`, `create`, `delete`
782
788
 
783
789
  ## Platform Resources
784
790
 
785
- - `r.settings` — User preferences, sessions, password changes, account deletion. `revokeOtherSessions({ keep_session_token? })` signs out every other device and keeps this key (and the proven session); `revokeAllSessions()` also revokes this key. Accounts with no password confirm `requestEmailChange({ new_email, email_code })` with a code from `requestConfirmationCode('email_change')`. With a project-bound key, `getPreferences()` includes app-scoped `content`; persist changes with `updateContent({ show_nsfw?, autoplay_video?, ai_enabled?, default_feed? })`.
791
+ - `r.settings` — User preferences, sessions, password changes, account deletion. `revokeOtherSessions({ keep_session_token? })` signs out every other device and keeps this key (and the proven session); `revokeAllSessions()` also revokes this key. Accounts with no password confirm `requestEmailChange({ new_email, email_code })` with a code from `requestConfirmationCode('email_change')`. With a project-bound key, `getPreferences()` includes app-scoped `content`; persist changes with `updateContent({ show_nsfw?, autoplay_video?, ai_enabled?, default_feed? })`. Notifications (0.7.72): `getPreferences()` returns `notifications.channels`, each kind (`post_mention`, `post_reply`, `post_repost`, `post_reaction`, `follow`, `message`, and the operator kinds) with `{ inApp, push, email }`; `updateNotifications({ channels: { post_reaction: { push: false } } })` changes that kind only, while `in_app`/`push`/`email` stay master switches over every kind. `disableAccount({ password })` (0.7.71) deactivates the account (hidden, nothing deleted; signing back in restores it), asked once; an API that does not serve deactivation answers 404 `NotFoundError`, so keep the control hidden until yours does.
792
+ - `r.signals` (0.7.71) — The member's engagement signals for their own ranking (For You), under their app-bound key (`agents:write`): `batch(events, { keepalive?, retry? })` sends up to `SIGNAL_BATCH_MAX` (100) `{ event_type, post_id?, dwell_ms?, metadata? }` to POST /signals/batch; `record(event)` sends one to POST /signals/post (for an API before the batch route, which answers that one 404). `event_type`: `view`, `click`, `dwell`, `react_up`, `react_down`, `save`, `hide`, `share`, `mute_source`, `open_chat`, `ask_agent`, `more_like`, `unhide`. Best-effort: a signal the server cannot store answers 200 with `recorded` 0 (or false) and a `reason` (`no_project_scope`, `project_db_unavailable`). `keepalive: true` is for a flush as the page is hidden or unloads: the request is on the wire before the call returns and is asked once.
793
+
794
+ ```typescript
795
+ await r.signals.batch([{ event_type: 'view', post_id: post.id }, { event_type: 'dwell', post_id: post.id, dwell_ms: 4200 }])
796
+ addEventListener('pagehide', () => { void r.signals.batch(pending.splice(0, SIGNAL_BATCH_MAX), { keepalive: true }).catch(() => {}) })
797
+ ```
786
798
  - `r.billing` — Usage tracking, checkout sessions, portal sessions, metered billing
787
799
  - `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)
788
800
  - `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`
789
- - `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.
801
+ - `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>"), `category: 'mentions'` (0.7.72) for a Mentions tab (mentions and replies only; `unreadCount` and `markAllAsRead` take it too), `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.
790
802
  - `r.uploads` — Legacy media upload URLs (prefer `r.storage` for new projects)
791
803
  - `r.github` — GitHub integration (getRepository, getTree, getFileContent, getCommits, createFromTemplate)
792
804
  - `r.deployments` — Deployment management (prefer `r.projects.deploy()` for new projects)
@@ -808,6 +820,7 @@ const { data: quote } = await r.appSubscriptions.previewTierChange({ tier: 'pro'
808
820
  await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorationDate })
809
821
  ```
810
822
  - `r.admin` — Admin operations (user management, stats, network settings); content reports: `listReports({ status? })`, `resolveReport(id)`, `dismissReport(id)` (project-bound keys see only their app's reports)
823
+ - `r.judgments` (0.7.71) — Jev's labels of one post. `get({ kind: 'post' | 'network_post', id })`: switch on `data.view` — `'full'` for the post's author and staff (`judgments` with answers, `safety` verdict, `viral.withheld`; staff also `reviewer_ids`, `review_note`), `'summary'` for other members (`category`, `politics` band in words with its `judgment_id`; never a probability or cell). `data.status === 'unavailable'` (returned, not thrown): hide label actions. `getPublic(projectId, { kind, id })` on an anonymous client: `view: 'category'` only. `dispute(judgmentId, { question_key: 'placement', proposed: 'cm' | … | 'not_political', note? })` (safety: `question_key: 'safety'`, author only): the author's dispute is an appeal; a repeat answers `existing: true`. Staff: `review(judgmentId, { outcome: 'uphold' } | { outcome: 'override', proposed, note?, apply_to_shared? })`, sent once (never retried): a compass override answers 202 `{ status: 'awaiting_reviewer', agreeing, needed }` until a second reviewer (or an agreeing Jev re-judge) proposes the same; a safety override to `'none'` that would free other posts throws 409 `shared_words` (`isJudgmentSharedWordsError(e)`, `e.details.shared_posts`) until resent with `apply_to_shared: true`. Budgets (60 reads/hour, 25 of one author's posts, 20 disputes/day) throw `RateLimitError` with `scope: 'action'` at once. Example: `const { data } = await r.judgments.get({ kind: 'post', id }); if (data.status === 'ok' && data.view === 'summary' && data.politics) await r.judgments.dispute(data.politics.judgment_id, { question_key: 'placement', proposed: 'not_political' })`
811
824
  - `r.inviteCodes` / `r.inviteCodesAdmin` — Invite code management
812
825
  - `r.organizationSettings` / `r.organizationSecurity` — Org configuration
813
826
  - Native follows: `nativeFollowState({ accountId, protocol, nativeId })`, `prepareNativeFollow({ clientMutationId, accountId, protocol, nativeId, action: "follow" | "unfollow" })`, `submitNativeFollow(id, { approve: true, event? })`, `nativeFollowStatus(id)`. Show the acting identity and native following/requested/unfollowed state separately from the Minds feed subscription. Nostr must sign the complete review event without dropping other contacts; stale lists require a fresh review. Keep mutation IDs and reconcile ambiguous outcomes without replay. Requires current follow scopes and API `NATIVE_SOCIAL_FOLLOW_PROJECTS` plus source controls.
@@ -817,14 +830,18 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
817
830
  - 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.
818
831
  - 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.
819
832
  - 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`; `feed` with `ref` is declared in 0.7.60 but not sent yet, so skip kinds you cannot open). 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? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`.
833
+ - Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped.
820
834
  - Viral read (0.7.59; off until the server enables it for the app): `const { data } = await r.protocols.getPublicViral({ media: 'video', category: 'science', limit: 20 })` (member key, users:read + posts:read, never cached) or keyless `visitor.protocols.getSharedViral(projectId, { media: 'video', compass: 'hide' })` (visitors send compass `all` or `hide` only). Params, all optional: `network` (all, minds, bluesky, activitypub, nostr), `category`, `media: 'video'`, `limit` 5/10/20/40, `cursor` (the last page's; a cursor whose 30-minute build expired ends the list), `compass`. Each item: `key` (stable; drop ones already shown), `protocol`, `post` (checking `protocol` narrows it: `ViralMindsCard` for minds, open at /post/:id; otherwise a `SocialPost`), `label` viral/rising/hot, `score`, `rank`, `category`, `velocity` (show counts with `countsReportedBy` as reported, never verified), `spread`. A visitor's read serves media only where the server cleared it, so even with `media: 'video'` some items arrive with the video withheld (empty `media`): `import { viralItemHasVideo } from '@recursiv/sdk'` and keep `data.items.filter(viralItemHasVideo)` before a vertical video player. `status` ok/building/screening/off: off resolves with no items, and `getSharedViral` also resolves an older API's 401 `missing_api_key` as off; `getPublicViral` throws every 401/403 (never off), so catch it and skip that step.
821
835
  - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass? })` (`compass: 'hide'` leaves out politics, 0.7.62); 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.
836
+ - One outlet count per story (0.7.71): a radar story (list, story read, archive), a World Today story (`outlets`, with `outlet_count` its total and `lane_mix` its lanes), a category strip story, a `feeds.top`/`feeds.stories` story and a search story carry `outlets: { total, mainstream, independent, other }`, the distinct outlets of the articles the story lists (an allow-listed outlet once, an unlisted host in `other`; the buckets sum to `total`). Show `outlets.total`; `sources.outlet` counts articles. `DiscoveryCluster.outletCount` reads it. An older server omits it.
822
837
  - Political stories (0.7.67): every radar story (`getPublicRadar`, `getSharedRadar`, a story read) and every World Today story (`r.curator.today()`) carries `political`: true when a quarter of its judged posts are politics, its theme or topic is politics, or its headline or summary names an election, a war or troop deployment, a court or justice, a legislature, or a party or politician. `compass: 'hide'` leaves those out whatever their theme; `r.curator.today({ compass: 'hide' })` does the same to the front page, the next story leads, and `meta.compass_filter` counts what was left out. A story from `r.feeds.stories()` or a `r.feeds.top()` lead carries `political` too, and both reads, and `r.curator.forYouWorld()`, leave political stories out under `compass: 'hide'` whatever their theme. An older server omits `political`: decide by `theme` as before.
838
+ - Hide politics as a floor (0.7.72): the radar, `r.curator.today()`, `getPublicTrends`, keyless `visitor.protocols.getSharedTrends(projectId, { protocol, compass: 'hide' })` (visitors send `all` or `hide` only; an older API answers 400 to `compass` there), the viral read, `r.discovery.videos` and a visitor's Discover posts apply `compass: 'hide'` for every caller. Where the operator does not serve the caller the full filter, the answer's `meta.compass_filter` is `{ status: 'applied', basis: 'flags', ... }`: left out by the server's own `political` flags and browse categories, no judgment read. Treat `applied` with or without `basis` as filtered. Choose sides and Mix sides still answer `unavailable` until the operator serves them, and the catalog's `compass_filter` capability still says when to offer Politics in Filters.
823
839
  - Post language: a `SocialPost` may carry `language`, the lower-case primary subtag the post declares (`'de'`, `'en'`; Bluesky `langs[0]`, Mastodon `language`, a Nostr NIP-32 `ISO-639-1` label). It is absent when the post declares none (most Nostr notes) and on posts from the source index; it is never guessed and no post is dropped for it. Order by it (the viewer's language first, undeclared next), never filter on it.
824
840
  - `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.
825
841
  - 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`.
826
842
  - Safe source IDs are preserved byte-exact. `external_id: null` means unsafe or malformed provenance was withheld, not an invented ID; `author.native_id` may likewise be absent or withheld. Preserve nulls in both list and detail views, and use the candidate's own `id` for `getCandidate`.
827
843
  - API authorization/validation/not-found/service errors propagate as normal SDK errors, including when the deployed API does not yet support these routes. Never substitute synthetic candidates or turn these failures into an empty queue. `getCandidate` rejects empty/whitespace and URL dot-segment IDs with `ValidationError` before sending; other IDs are encoded and validated by the server.
844
+ - `ingestionHealth()` (0.7.71) reads `GET /protocols/ingestion/health` with no query (the API rejects one; scope comes from the key): `{ data: ProtocolIngestionHealthReport }` with `runtime` switches, `lanes` (per lane `jobs`, `current` open job with `position` { feed_index, term_index, page }, `pages_committed`, `consecutive_failures`, `lease_expired`, `waiting_since`, `starved_since`; `last_done_at`; 24-hour `activity` with `reasons`; `dead_letters` grouped by reason and position) and `circuits` (provider or fetch-host rows that are open, counting or killed). Same key and authority as `listCandidates` but no `content_intelligence_admin` binding. A section that did not answer is null and listed in `unavailable`: show it as unknown, not as zero. Errors throw.
828
845
  - `r.inbox` — Notification feed
829
846
  - `r.network` — Federation/network config
830
847
  - `r.simulator` — Simulation/testing
@@ -1011,8 +1028,8 @@ publication; history stores no signature, signing key or intent. Keep the acting
1011
1028
  user/app-bound result IDs and discard UI state after an account switch. The last
1012
1029
  read-back observation is separate from retained publication/deletion evidence.
1013
1030
 
1014
- Standalone public text posts: `prepareNativePost({ clientMutationId, text, accountIds })` → review → `submitNativePost(id, { approve: true, deliveryIds })`. Use `nativePostStatus(id)` / `nativePostHistory({limit,cursor})` for private durable outcomes and `prepareNativePostRemoval(id,{deliveryIds})` → `removeNativePost(id,{approve:true,removals:[{deliveryId,removalId}]})` for reviewed own-post removal. Check `publish_text`; no reply parent or audience override. Attach already-uploaded Minds images with `media: [{ url, alt?, width?, height?, mimeType? }]` (at most four JPEG/PNG/WebP/GIF, Minds-hosted HTTPS URLs only, a Mastodon GIF must be the only attachment) on accounts that also advertise `publish_media` — grants made before image scopes existed keep working for text and must be reconnected first. Always pass the member's own `alt`; an omitted one is sent empty, never invented. The receipt reports `media: { count, alt_missing }`. Preserve mutation IDs, never create a new send to recover uncertainty.
1015
- Bluesky quote: accounts must advertise `quote`; call `prepareNativePost({clientMutationId,text,accountIds,quote:{protocol:'bluesky',nativeId:sourceAtUri}})`, review each returned quote/account/comment, then approve via the same submit method. The server binds the source CID and rechecks permissions before sending. No cross-network quote conversion. Public reads expose one filtered `post.quote` (`available` with `post`, or `unavailable` without source details). Available quotes may include `embeddedReferences`: exact standalone source-reference lines in the unchanged outer `text`. Omit them only when the quoted original is actually visible; retain them for unavailable or locally hidden quotes. Nostr references are decoded and matched by the server, without following relay hints.
1031
+ Standalone public text posts: `prepareNativePost({ clientMutationId, text, accountIds })` → review → `submitNativePost(id, { approve: true, deliveryIds })`. Use `nativePostStatus(id)` / `nativePostHistory({limit,cursor})` for private durable outcomes and `prepareNativePostRemoval(id,{deliveryIds})` → `removeNativePost(id,{approve:true,removals:[{deliveryId,removalId}]})` for reviewed own-post removal. Check `publish_text`; no reply parent or audience override. Attach already-uploaded Minds images with `media: [{ url, alt?, width?, height?, mimeType? }]` (at most four JPEG/PNG/WebP/GIF, Minds-hosted HTTPS URLs only, a Mastodon GIF must be the only attachment) on accounts that also advertise `publish_media` — grants made before image scopes existed keep working for text and must be reconnected first. Always pass the member's own `alt`; an omitted one is sent empty, never invented. The receipt reports `media: { count, alt_missing }`. A photo-only post (no words) passes `text: ''` with at least one `media` item: Bluesky gets an empty text with the images, Mastodon a status with only the photos, each `alt` as its description; `''` without media or text of only spaces is refused, and replies always need text. Preserve mutation IDs, never create a new send to recover uncertainty.
1032
+ Bluesky quote: accounts must advertise `quote`; call `prepareNativePost({clientMutationId,text,accountIds,quote:{protocol:'bluesky',nativeId:sourceAtUri}})`, review each returned quote/account/comment, then approve via the same submit method. The server binds the source CID and rechecks permissions before sending. Mastodon quote (0.7.71): `quote:{protocol:'activitypub',nativeId:statusUri}`; the member's server must support quote posts (API version 7) and allow it, else `quote_not_supported` / `reply_not_allowed`, never a link. Nostr quote: `prepareNativeNostr(id,{action:'publish',content,quote:{protocol:'nostr',nativeId:eventId}})` adds NIP-18 `q`/`p` tags and a `nostr:nevent` line; sign and submit as usual. No cross-network quote conversion. Public reads expose one filtered `post.quote` (`available` with `post`, or `unavailable` without source details). Available quotes may include `embeddedReferences`: exact standalone source-reference lines in the unchanged outer `text`. Omit them only when the quoted original is actually visible; retain them for unavailable or locally hidden quotes. Nostr references are decoded and matched by the server, without following relay hints.
1016
1033
 
1017
1034
  Public post search (API/SDK 0.7.35): `const { data: page } = await r.protocols.searchPublicPosts({ protocol: 'nostr', query: 'open social', limit: 10 })`. Bluesky/ActivityPub require a selected `accountId` from `nativeSocialAccounts()` with `post_search`; older Bluesky grants must reconnect with consent. Open their results with `getConnectedSocialConversation({ protocol, accountId: page.readerAccountId, post: item.nativeId, readRef: item.readRef })` and pass the same accountId to `reportSource` for post reports. Nostr uses `getPublicConversation`. Pages report provider/observedAt and bounded coverage; no invented exhaustive or global search. Keep cursors paired with the exact owner, query, account, limit and connection revision. No writes, credentials or new ingestion are part of search.
1018
1035
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.70",
3
+ "version": "0.7.72",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",