@recursiv/sdk 0.7.123 → 0.7.195

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/llm.md CHANGED
@@ -100,7 +100,7 @@ const { data: logs } = await r.projects.deploymentLogs(project.id, deployment.id
100
100
 
101
101
  Methods: `list`, `get`, `create`, `delete`, `deploy`, `executeCode`, `sandbox`, `createSandbox`, `stopSandbox`, `deployment`, `deploymentLogs`, `deployments`
102
102
 
103
- Operator API/frontend coordination: `r.deployments.reserveRelease(input)`, `getRelease(releaseId, projectId)`, `advanceRelease(releaseId, input)`, `startRelease(releaseId, input)`, `finishRelease(releaseId, input)`, `recoverRelease(originalReleaseId, input)`. Example read: `const { data: release } = await r.deployments.getRelease(releaseId, projectId)`. Use a project-bound `projects:read`/`projects:write` operator key; follow [shared release phases, live proof and recovery](../../docs/deploy/shared-releases.md). Keep capabilities out of logs; uncertain operations retain ownership.
103
+ Operator API/frontend coordination: `r.deployments.reserveRelease(input)`, `getRelease(releaseId, projectId)`, `advanceRelease(releaseId, input)`, `startRelease(releaseId, input)`, `finishRelease(releaseId, input)`, `recoverRelease(originalReleaseId, input)`. Example read: `const { data: release } = await r.deployments.getRelease(releaseId, projectId)`. Use a project-bound `projects:read`/`projects:write` operator key; follow [shared release phases, live proof and recovery](../../docs/deploy/shared-releases.md). Initial reservation also accepts optional `coordinator_identity_token` after the compatible API is verified live; invalid supplied proof never falls back to tokenless. Keep capabilities and tokens out of logs; uncertain operations retain ownership.
104
104
 
105
105
  ### r.storage — Bucket-based file storage
106
106
 
@@ -161,6 +161,12 @@ const { content, conversationId } = await r.agents.chatStreamText(agent.id, {
161
161
  conversation_id: existingConvId, // optional
162
162
  })
163
163
 
164
+ // "New chat" with the member's own personal agent: an empty thread in this key's app (201), or the member's existing
165
+ // empty one there (200, created: false). 404 agent_not_found for no agent or another network's; 403 not_owner otherwise.
166
+ const { data: chat } = await r.agents.createConversation(myAgentId) // { id, type, name: null, members, created, ... }
167
+ await r.agents.chatStreamText(myAgentId, { message: 'Plan a weekend in Lisbon', conversation_id: chat.id })
168
+ const { data: chats } = await r.agents.conversations(myAgentId) // chats[].name: own name, else first 60 chars of the first message, else null
169
+
164
170
  // The app's agent directory, searched and sorted on the server (0.7.71). Keyless works for visitors:
165
171
  // new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }) on the app's API host (public profiles only).
166
172
  const { data: found, meta } = await r.agents.discover({ q: 'space', sort: 'popular', limit: 20 }) // sort: popular | recent | name
@@ -173,7 +179,7 @@ await r.agents.grantProjectAccess(agent.id, {
173
179
  })
174
180
  ```
175
181
 
176
- 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`
182
+ 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` (each with `name`), `createConversation` ("New chat" with your own personal agent; send with `chatStream`'s `conversation_id`), `discover` (search and sort the app's published agents, cursor paged, keyless-readable), `listDiscoverable`, `leaderboard`, `grantProjectAccess`, `revokeProjectAccess`, `resetRequestCount`, `getByClaimToken`, `claim`, `orchestratorStatus`
177
183
 
178
184
  ### r.brain — AI assistant messaging
179
185
 
@@ -260,6 +266,15 @@ const first = events[0]
260
266
  const { data: step } = await r.evidence.recordDetail({ type: 'swarm', id: swarmId }, first.id, first.kind)
261
267
  // step: model_call → { systemPrompt, promptMessages, responseText, reasoningText, toolCalls }
262
268
  // tool_call → { toolName, args, result, success }
269
+
270
+ // Save a set once, re-run it after every change so runs compare like with like
271
+ const { data: saved } = await r.evidence.createEvalSet({
272
+ name: 'Escalation policy',
273
+ cases: [{ input: 'New account, $9.6k order', expected: { answer: 'ESCALATE' }, trap: true }],
274
+ })
275
+ const { data: rerun } = await r.evidence.simulate({ target: { type: 'swarm', id: swarmId }, eval_set_id: saved.eval_set_id })
276
+ const { data: sets } = await r.evidence.listEvalSets() // the key's org, or your own sets
277
+ const { data: file } = await r.evidence.exportRun(rerun.run_id, 'csv') // { filename, mimeType, content }
263
278
  ```
264
279
 
265
280
  ### Task checks — how the org knows a task's work is done
@@ -728,11 +743,27 @@ const top = await r.feeds.top({ ref: 'builtin:all', limit: 10, session }) // m
728
743
  // top.lead: the story to open with; every target is in the app (post id, /social/post/<protocol>/<id>, story id), never a URL out
729
744
  const { data: next } = await r.feeds.stories({ after: storyId }) // the story tail, same ranker as the lead
730
745
  const page = await r.feeds.publicTop({ projectId, ref: 'all' }) // a visitor's top: safe items only, keyless, edge-cacheable
746
+ const live = await r.feeds.firehose({ session, limit: 20, network: 'all' }) // 0.7.195: the member's live stream, never repeated in a session
747
+ // live.items: FeedTopItem + { network, state: 'breaking' | 'viral' | null, publishedAt, chosenAt, card? }; live.retract: refs to remove
748
+ // reveal one at a time; read again no sooner than live.meta.refillAfterMs (404 while off; 429 and 503 carry Retry-After)
731
749
  const feed = r.discovery.createFeed({ networks, top: { ref: 'builtin:all', session }, seen: seenRefs })
732
750
  ```
733
751
 
734
752
  Each answers 404 while the app's operator has it off; `feeds.top` is private to the member and 503 `feed_top_unavailable` carries Retry-After. With a visible view of a top item, send `block: 'top'` and its position. In a feed session the top's read starts with page 0's reads and the top holds the page at most 1.5 s from its start, the by-id reads of its posts included; off, failed or late, the session is unchanged, and the radar's story reads its posts as soon as the radar answers either way. Page 0 then opens with the top's lead story and items (each item carries `top: { ref, position, block }`), its lanes replace the networks' first reads, and no hot page repeats a top item. `seen` (Minds ids and `<protocol>/<nativeId>` refs) leaves seen posts out of the hot pages, walking on past a page left under five posts (up to three more). `seenClusters` (`Map<id, { n, clicked }>`) and a visitor's `bucket` rotate the story when the top names none (`pickRadarCluster`).
735
753
 
754
+ ### r.alive — The alive tick (member, 0.7.193)
755
+
756
+ ```typescript
757
+ // One request per tick carries every open screen's interests; each part is answered only when asked for.
758
+ const tick = await r.alive.tick({ followingSince: newestRow.created_at, posts: idsOnScreen, thread: { id: openPostId, since: newestReplyAt } })
759
+ if (tick.following?.fresh) await feed.loadNew() // the rows wait behind the new-posts pill; nothing moves
760
+ for (const [id, n] of Object.entries(tick.counts ?? {})) updateCounts(id, n) // score, upvote_count, reply_count, reposts_count
761
+ if (tick.thread?.newReplies) showNewReplies(tick.thread.newReplies) // "3 new replies", revealed on a tap
762
+ setTimeout(next, tick.nextAfterMs) // the server's pace (30 s by default, 120 s under load)
763
+ ```
764
+
765
+ The member's own key of the app, `users:read`. Off until the operator sets `ALIVE_TICK_ENABLED`: the tick throws `NotFoundError` (code `alive_disabled`) and the app stops ticking. `following.fresh` is true only for a top-level Minds post after `followingSince` by someone the member follows that they may be served (blocks either way, mutes, tier 1, moderation, safety setting); `counts` holds only posts the member may be served (a repost, quote or reply in view is left out: keep the number you have); `thread.newReplies` never counts the member's own replies, and is at most 100. At most 30 `posts` (400 `too_many_posts`), 4 ticks a minute per member (`RateLimitError`). Asked once (`retry: false`): the next tick is the retry. Tick only while the page is visible.
766
+
736
767
  ### r.search
737
768
 
738
769
  ```typescript
@@ -744,9 +775,11 @@ for (const section of answer.order) render(section, answer.sections[section])
744
775
  const { data: web } = await r.search.query({ q: 'hockey', sections: ['media'], own_images: shownPictures })
745
776
  ```
746
777
 
747
- `media` (0.7.123): videos (YouTube ids as `embed`, PeerTube embed pages), news articles, pictures, sound and one `about` card (Wikipedia), from Brave Search, Wikipedia, PeerTube and Openverse with strict safe search. It answers only when `sections` names it (the default sections leave it out: each answer may spend a paid web search). Every picture passes the same vision check a post's picture does: a visitor sees only pictures judged safe, and a member who turned Show NSFW on gets a suggestive one marked `sensitive` (blur it). Every `thumbnail` is on the API's signed media proxy; play a YouTube embed through youtube-nocookie.com by its player's rules and show each `attribution`. `own_images` (how many pictures the page already shows) lets the server ask for the web's pictures only when fewer than six. Only the networks the server offers it to get it (others: `omitted` `unavailable`); `providers` says what each source did (`timeout`: ask again shortly). Visitors read it as `publicDiscovery.search` `tab: 'media'` (`PublicSearchPage.media`).
778
+ `media` (0.7.123): videos (YouTube ids as `embed`, PeerTube embed pages), news articles (0.7.133: also today's articles about the words from the server's news lanes, `source: 'eventregistry'`, `providers.news_index`; show the headline, `description` excerpt, `publisher`, date, `thumbnail` and link only), pictures, sound and one `about` card (Wikipedia), from Brave Search, Wikipedia, PeerTube and Openverse with strict safe search. It answers only when `sections` names it (the default sections leave it out: each answer may spend a paid web search). Every picture passes the same vision check a post's picture does: a visitor sees only pictures judged safe, and a member who turned Show NSFW on gets a suggestive one marked `sensitive` (blur it). Every `thumbnail` is on the API's signed media proxy; play a YouTube embed through youtube-nocookie.com by its player's rules and show each `attribution`. `own_images` (how many pictures the page already shows) lets the server ask for the web's pictures only when fewer than six. Only the networks the server offers it to get it (others: `omitted` `unavailable`); `providers` says what each source did (`timeout`: ask again shortly). Visitors read it as `publicDiscovery.search` `tab: 'media'` (`PublicSearchPage.media`). A PeerTube item the server could read off its own instance carries the same video as `post` (0.7.155), a native Fediverse `SocialPost` (`software: 'peertube'`) that plays in place from its channel: show it instead of opening `url`. A `source: 'youtube'` video (0.7.181) is one the YouTube Data API found for the words, asked only when the web's own videos about them are thin or the words name sport or music, within the server's daily YouTube quota (`providers.youtube`: `skipped`, `budget` past the quota, `unconfigured` without a key): it always carries YouTube's player as `embed`, so play it in place (no autoplay with sound, nothing drawn over the player, YouTube named as the source) rather than opening `url`.
748
779
 
749
- `trending` joins when the answer is thin or old, so a search never dead-ends. A thin answer steps down the search ladder (0.7.79): the word a typo meant, the known words with the rest dropped ("wiggly memes" searches "memes"), what is popular in the category the words are about, then on the app. Every item says how in `via` (`SearchVia`) and `answer.ladder` (`SearchLadder`) says what happened: label it honestly ("No exact matches for X", "Did you mean Y?", "Popular in Comedy right now"); `ladder.unavailable` names sections the key cannot read. Visitors read the same ladder keyless through tRPC `publicDiscovery.search` (`PublicSearchPage`). `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`).
780
+ `video_lead` (0.7.173): every clip the answer holds (Top's and Latest's Minds clips, the open networks', the media section's videos) in the order to lead and play it, each `{ section, index, ref, source, on_subject, published_at }`: the most about the words first (`on_subject` `on`, `loose`, `off`), then the freshest, a Minds member's clip winning a tie; the `off` tail (the popular clips, about none of the words) keeps the answer's own order. Lead with `video_lead[0]`; a visitor's page (`publicDiscovery.search`) carries it too, and its `videos` tab's `items` already come in that order. Absent with no clip, from older servers, or when the operator turned it off.
781
+
782
+ `trending` joins when the answer is thin or old, so a search never dead-ends. A thin answer steps down the search ladder (0.7.79): the word a typo meant, the known words with the rest dropped ("wiggly memes" searches "memes"), what is popular in the category the words are about, then on the app. Every item says how in `via` (`SearchVia`) and `answer.ladder` (`SearchLadder`) says what happened: label it honestly ("No exact matches for X", "Did you mean Y?", "Popular in Comedy right now"); `ladder.unavailable` names sections the key cannot read. Visitors read the same ladder keyless through tRPC `publicDiscovery.search` (`PublicSearchPage`). A visitor's posts tabs may carry `picture` (`SearchPicture`, 0.7.162): `kind`, `textHeavy`, `tileQuality` (0-3) and an optional `focal` point, layout hints from a background picture read, absent until judged: show a `textHeavy` picture whole, crop others at `focal`, never treat it as a safety signal. On the `deep` stage `picture.showsQuery` (0.7.190) says whether the picture itself shows the query (make a big tile where it is `true`); it is absent unless the operator turned on `SEARCH_PICTURE_SUBJECT`, and until judged. `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`).
750
783
 
751
784
  ### r.curator — World Today and its story pages
752
785
 
@@ -869,7 +902,7 @@ const frame = mountSealedFrame({ container, frameOrigin: 'https://sealed.minds.c
869
902
  ```
870
903
  - `r.billing` — Usage tracking, checkout sessions, portal sessions, metered billing
871
904
  - `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)
872
- - `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`
905
+ - `r.boosts` — Promoted posts (micro-USD): `create`, `mine`, `balance`, `pause`, `resume`, `cancel`, `recordEvent`, `stats(campaignId)` (views, clicks, click rate, cost per 1,000, daily series); a feed page's boosts to splice in with `slots({ surface: 'all', limit: page.length, exclude: ids })` (visitors: `publicSlots({ projectId, ... })`), then `spliceBoostSlots(page, res.data)`; buy credit with `topup({ amount_cents, return_url })` then `confirmTopup(sessionId)` (credits once); pay with tokens via `topupWithTokens({ tokens, request_id })` when `balance().meta.token_topup.available`; admin `listPendingReview`, `review`, `grant`
873
906
  - `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.
874
907
  - `r.uploads` — Legacy media upload URLs (prefer `r.storage` for new projects)
875
908
  - `r.github` — GitHub integration (getRepository, getTree, getFileContent, getCommits, createFromTemplate)
@@ -897,21 +930,29 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
897
930
  - `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' })`
898
931
  - `r.inviteCodes` / `r.inviteCodesAdmin` — Invite code management
899
932
  - `r.organizationSettings` / `r.organizationSecurity` — Org configuration
933
+ - `r.settings` explicit starting choices (unreleased): `const { data: current } = await r.settings.getExperience(); const { data: saved } = await r.settings.updateExperience({ version: current.version, goals: ['explore'], interests: ['technology'] });` Open `saved.discovery?.href` for existing category results; this does not change whole-feed ranking or privacy. `undoExperience({ version: saved.version })` restores the last choices and refuses a stale version. Own project-bound member key only, `settings:read` / `settings:write`; goal ids: build, create, explore, connect, private_ai, earn. Categories are canonical browse ids; no chat text is saved.
900
934
  - 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.
901
935
  - Native reposts: `nativeRepostState({ accountId, protocol, nativeId })`, `prepareNativeRepost({ clientMutationId, accountId, protocol, nativeId, action: "repost" | "unrepost" })`, `submitNativeRepost(id, { approve: true, event? })`, `nativeRepostStatus(id)`. Requires `NATIVE_SOCIAL_REPOST_PROJECTS`, account capability and explicit approval; Nostr signs the exact review (kind 6 or own kind 5). Unknown attempts only reconcile. Bluesky connections need the new repost scope; ActivityPub uses write:statuses. Native likes: `nativeLikeState({ accountId, protocol, nativeId })`, `prepareNativeLike({ clientMutationId, accountId, protocol, nativeId, action: "like" | "unlike" })`, `submitNativeLike(id, { approve: true, event? })`, and `nativeLikeStatus(id)`. Display the selected account, source and action before approval. Only Nostr supplies `event`: sign the exact public `signing.event` in the review; never transmit a private key. Preserve mutation IDs on ambiguous responses and refresh unknown results instead of resending. Read capabilities before offering actions; old reply-only connections need reconnecting for likes. Nostr results describe the connected relay, not every public relay. The app-specific `NATIVE_SOCIAL_LIKE_PROJECTS` switch and source controls must be enabled on the API.
902
936
  - Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, media?, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source, exact `wireText` and every reviewed `media` item with its alt text; connection is not publication approval. `media` takes the same `NativeMediaInput[]` as `prepareNativePost` and needs the account's `publish_media` capability. `linkedPostAccountIds` explicitly selects different-network standalone posts with a source link, never matching threaded replies or a same-network denial fallback. Preserve mutation IDs, show per-destination results and reconcile uncertain attempts without reposting. To review selected unsent or definitively rejected deliveries again, use recoverFrom with a new mutation ID, the same source/accounts and freshly reviewed text; preserve that ID on uncertain preparation. Only saved recoverable destinations qualify, current gates still apply, and replacedBy disables old sends atomically. Never restart unknown, accepted or published deliveries. The new preview still needs explicit approval. Saved delegated drafts/results: `const { data: history } = await r.protocols.nativeReplyHistory({ limit: 20 })`; use its opaque cursor for the next page and `nativeReplyStatus(item.id)` for details. This is a private read, not a write or provider refresh, and includes Nostr results marked type=nostr. Use nativeNostrReplyResult(item.id) to check that recorded event on its relay without signing or resending. Discard page state on owner/app changes. See README for examples and the callback, revocation and removal contracts.
903
937
  - `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. Open network ids (0.7.87, readers first): `import { SOCIAL_PROTOCOL_IDS, isSocialProtocolId } from '@recursiv/sdk'` lists every network a post, account or `SocialSourceRef` may name: bluesky, activitypub, nostr and farcaster (a Farcaster account by its FID, "3"; a cast by `<fid>:0x<40 lowercase hex>`). Listing an id serves nothing: read only what `publicNetworks()` lists, and skip an id this SDK does not know. Writes stay narrower: a native reply, follow, like or repost takes only `NATIVE_WRITE_PROTOCOLS` (bluesky, activitypub, nostr; `isNativeWriteProtocol(post.protocol)` before offering one), and a feed top's `networks` always names those three while a Farcaster lane may be absent (`networks.farcaster ?? []`). 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`; a Fediverse community a groups read lists under `open_network` (0.7.109): `const { data: page } = await r.protocols.getOpenGroupThreads({ group: entry.actor, limit: 20 })` reads its newest threads read-only (`page.group`, `page.items`); `limit` is 1 to 25, `compass: 'hide'` leaves out the threads in politics, and the next page passes `cursor: page.cursor` while it is set (404 while the app does not offer groups from other networks); private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. `getFollowingFeed` sources: a slow account is `late` (status `late`, or `ok` with `late: true` while its held first page is shown; older APIs say `unavailable` with `late: true`), never a failure: read the next page for its posts (`lateNetworks` names its network). 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. The author's own thread (0.7.81): `thread` holds the focus author's continuation below the post, oldest first (a "1/" post's 2/, 3/, 4/ ..., at most 20 parts, each held to the same moderation and member controls as a reply); its first part is not repeated in `replies`, so render `focus`, `thread`, then `replies` (`thread ?? []`: older APIs omit it, and a `replies: 'none'` read answers none). A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. 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`.
904
938
  - 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.
905
- - Native networks (0.7.89): every eligible member is on ActivityPub, AT Protocol and Nostr by rule. `const { data } = await r.protocols.networks()` lists `{ network, state, reason, mode, handle, engageDefault }` (read-only; `ready` = set up on their first post or interaction, show "Will be set up when you first post or interact"). `activateNetwork(network)` = "Set up now" (`pending` + `retryAt` over budget; 409 `network_off` after the member's off switch). `setNetwork(network, { enabled: false, removePast: true })` turns it off and removes what was shared; `{ enabled: true }` turns it back on (403 `network_locked`, 409 `not_rolled_out`/`age_band_required`). Needs users:read (+ users:write to change). A linked outside account is a Settings choice, never a step in a main flow.
939
+ - Nostr signing (0.7.192): `nativeNostrAccount()` answers `signing: 'client'` (the member's own key: prepare, sign the exact event, submit) or `signing: 'server'` with `mode: 'managed'` (a key Minds holds: `prepareNativeNostr` is a side-effect-free preview; after the member approves, `prepareNativeNostr(id, { ...input, send: true })` signs, sends and returns `receipt`; a like/repost/follow review submits `{ approve: true }` with no `event`; `engage*` with `identity: 'native'` acts as it). A managed key's `enabled` follows the member's Nostr presence, not the app's Nostr write switches; for either key, like/repost/follow (and their undos) are listed only while the app's review route for them is on, so offer exactly the listed capabilities.
940
+ - Native networks (0.7.89): every eligible member is on ActivityPub, AT Protocol and Nostr by rule. `const { data } = await r.protocols.networks()` lists `{ network, state, reason, mode, handle, engageDefault }` (read-only; `ready` = set up on their first post or interaction, show "Will be set up when you first post or interact"). `activateNetwork(network)` = "Set up now" (`pending` + `retryAt` over budget; 409 `network_off` after the member's off switch). `setNetwork(network, { enabled: false, removePast: true })` turns it off and removes what was shared; `{ enabled: true }` turns it back on (403 `network_locked`, 409 `not_rolled_out`/`age_band_required`). Needs users:read (+ users:write to change). A linked outside account is a Settings choice, never a step in a main flow. Taking the managed Nostr key home: `exportNostrKey({ password } | { emailCode })` (code from `settings.requestConfirmationCode('nostr_key')`) returns the `nsec` (show once, never log); `nostrRebindChallenge()` then `rebindNostrKey(signedEvent)` proves the device holds the same key (mode `attached`); `deleteManagedNostrCopy(stepUp)` deletes Minds' copy (409 `rebind_required` before the rebind; 409 `too_many_published` keeps it), after which Nostr actions are signed on the device and Minds' own notes still come down with takedowns it signed first. Never auto-retried.
941
+ - Famous accounts (0.7.151): `const { data } = await r.protocols.getPublicPopularProfiles({ protocol: 'all', limit: 24 })` reads the reviewed famous accounts of AT Protocol, the Fediverse and Nostr in one ranking (most famous and recently active first: followers plus readers of the person's Wikipedia article, categories interleaved, politics at most one in four and rotated across sides), then the providers' own suggestions. Page with `cursor` for infinite scroll (it holds its list across an hourly refresh for a few hours; on 400 `invalid_input` start again); `null` is the end. `category` (one of `data.categories`) narrows the list; `compass: 'hide'` (the member's Hide politics) leaves political accounts out of any list. Items carry `category` on `protocol: 'all'`. A list the catalog leads says so: `provenance.ranking` is `famous_accounts` (provider named in plain words, `fame and recent activity`, with no company name, so an app can show it as is), and on a network's own list `provenance.sources` keeps the provider's own label and read time for the accounts after the catalog's. A `protocol: 'all'` cursor is also refused after a deploy changed the catalog. Needs users:read and posts:read; 503 until the app's index has built the list.
906
942
  - 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.
943
+ - One-tap engage (0.7.191): `engageLike|engageRepost|engageFollow({ protocol, nativeId, clientMutationId, action, identity?, accountId? })`, `engageReply({ clientMutationId, text, parent, media?, identity?, accountIds? })`, `engageQuote({ clientMutationId, text, quote, ... })`. Native: `{ identity: 'native', queued: false, delivery, actedAs }` or 202 `{ queued: true, interactionId, expiresAt, retryAt, actedAs }` (sent once the identity is set up; `pendingInteractions()` lists them). Linked: the review to submit as before, plus `identity: 'linked'`, `actedAs` and once `notice: 'engage_default_linked'`. `setEngageDefault(network, 'native' | 'linked')`. Refusals: 409 `native_network_off` (with `reason`), `linked_account_required`, `native_post_from_app` (a plain post is never native), 429 `too_many_pending`, 503 `native_network_unavailable` (reason `adapter_missing`: that network cannot send a tap natively yet; a linked account acts instead when there is one). Keep `clientMutationId` across retries; never auto-retried.
944
+ - AT Protocol password claim (0.7.191): the member's AT Protocol account is created with their verified email and Minds keeps only a revocable app password. `claimAtprotoPassword({ password } | { emailCode })` (emailCode from `POST /settings/confirmation-code` purpose `atproto_password`) has the PDS email a reset code; answers `{ handle, sentTo }` (masked). 403 `step_up_required`/`step_up_failed`, 404 `no_identity`, 409 `needs_verified_email`/`email_changed`, 429 `rate_limited`. Never auto-retried.
945
+ - AT Protocol grant (0.7.191): presence `needs_action` reason `needs_atproto_grant` (an account made before Minds ran it natively) or `error` reason `app_password_revoked` -> `grantAtproto({ password })` with the AT account's password (main, or a non-privileged app password); Minds keeps only a new revocable app password. Answers `{ handle, presence }`. 403 `password_failed`, 400 `privileged_app_password`, 409 `identity_mismatch`, 404 `no_identity`, 429 `rate_limited`. Never auto-retried.
907
946
  - 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`. With no category about one row in four is an open-network clip (0.7.78): `isWatchNetworkClip(row)` tells `{ kind: 'social', id, post: SocialPost }` from a `Post`; open and react to it as the network's post (its `id` is never a Minds id).
908
947
  - 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; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), 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. 0.7.84: a featured module carries `theme` only when its lead window was themed so and most of its own posts were judged into that category (a category feed's posts, by the read's category filter); a feed with no category titles every module by its kind alone ("Videos", not "Videos in Technology" over an unjudged Nostr clip). A post is shown once by every identity (`discoveryItemIdentities(item)`: its key, an open-network post's network id, and each media file by address and by content hash, so a second note carrying the same clip is the same post) across a session's pages, a progressive feed's sources (`withoutSeenItems` takes identities or keys), `mergeDiscoveryArrival` and gallery pooling; `withoutRepeatedDiscoveryItems(rows, shown)` does the same for an app's own rows.
909
948
  - 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`, and a side or `mix` only where the catalog lists `compass_sides`). 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.
910
949
  - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass?, media? })` (`compass: 'hide'` leaves out politics, 0.7.62; `media: 'video'`, 0.7.73, Bluesky: the network's own window of video posts, a `kind: posts` page paged by its cursor, no category or layout, 404 `unsupported_protocol` for a network without one, 400 on an API that predates it; keyless `getSharedTrends(projectId, { ..., media: 'video' })` holds only clips a visitor may see and counts the rest in `mediaFilter.withheld`); 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.
911
950
  - 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.
951
+ - Keyless category surface (0.7.165): `const { data } = await visitor.protocols.getSharedCategorySurface(projectId, { category: 'science', compass: 'hide' })` gives a signed-out category page its `stories`, `network` (open-network posts, with `by_protocol`) and `clips` in one read, the sections a member's `r.discovery.category()` reads from the same server cache, never `people` or `groups`. Every visitor floor runs again on each read (tier-1, held and blocked-instance items, safety and term floors, NSFW, media only where cleared for visitors: a post's pictures keep their `width` and `height`). `compass`: `all` or `hide` (Hide politics on all three, counted in `data.meta.compass_filter`); a side or Mix only where the app's catalog lists `compass_sides` for visitors (else a 400), answered with Everything's sections marked `unavailable`, since no section carries a placement. A short or failed section says why in `reason`. Send only `category` and `compass`: the answer is one per app, category and choice, so the edge may keep it 30 s.
912
952
  - 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.
913
953
  - 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`, and a side or `mix` only where the catalog lists `compass_sides`, 0.7.116; 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; `compass_sides` (0.7.116) says the viewer may choose sides and Mix too (a visitor only where the operator serves visitors the whole filter).
914
954
  - 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.
955
+ - Nostr mentions (0.7.143): a Nostr `SocialPost` (and its available `quote.post`) may carry `mentions`, the people its `text` names with NIP-27 references that the server can name: `{ pubkey, displayName, handle, references }`, at most eight, in the order the text first names them. Draw `@displayName` in place of each string in `references` (the exact `nostr:npub1…`/`nostr:nprofile1…` references in `text`), and open the person with `protocol: 'nostr'`, `actor: pubkey`. `handle` is their NIP-05 name once its domain confirmed it, else their npub. A person the server has not read, whose metadata hides them, or whom the app's moderation or the member's own blocks and mutes hide, is left out: show that reference as the text writes it. The server never follows an nprofile's relay hints. A quoted note's author is named by the newer of what the read relays hold and what the server last read of them (a key only when neither has any), so an author who hides themselves withholds the quote at once.
915
956
  - `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.
916
957
  - 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`.
917
958
  - 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`.
@@ -1012,6 +1053,8 @@ Pagination: pass `limit` and `offset`. Check `meta.has_more`.
1012
1053
 
1013
1054
  Auto-retry on 429/5xx built in (exponential backoff, default `maxRetries: 2`), except a 429 with `X-RateLimit-Scope: action` (one member action past its budget): never retried, `RateLimitError.scope === 'action'`, `resetAt` = when it reopens; show it on that action, do not pause the app. A read that holds its own deadline can ask once: `r.posts.list({ ...params, retry: false })` and `r.curator.forYou({ ...params, retry: false })` (0.7.59) throw the first 5xx or 429 with no retry and no `Retry-After` sleep; `retry` is never sent. It rides in the params so a reader that wraps `posts.list` and forwards its params keeps it. Discover's feed sessions (`discovery.createFeed`, `createPersonalFeed`, a `DiscoveryFeedSession` over your own `posts.list` wrapper) read Minds posts this way, as they read the open networks, and report a busy read in `failedSources`; a wrapper that rebuilds the params from chosen fields must copy `retry` too. Member actions with a per-member budget are sent once and never auto-retried: `reports.create`, `moderation.appeal` and `protocols.reportSource` throw the 429 at once (`RateLimitError`, `message` is member-facing, `retryAfter` = seconds until the window opens; a budget refusal carries `X-RateLimit-Scope: action` and no `Retry-After` header).
1014
1055
 
1056
+ Sealed answer reports (0.7.152; deploy the supporting API before client adoption): `await r.reports.create({ target_type: 'sealed_answer', target_id: appProjectId, reason: 'harmful', details: 'The exchange the member chose to show staff' })`. Use a human-owned, non-agent project key with `posts:write` and its exact project id. This intake does not recheck live app membership; `/sealed/reports` additionally applies the gateway's membership guard. These queue items do not accuse the reporter or hold their unrelated posts/account deletion; a reviewer can close them without account enforcement. Older `user` reports remain account reports and are not reclassified. Existing native clients must adopt this target after the API/SDK rollout; source support alone does not fix them. Every answer request, including a join, spends the shared report budget. Read `data.id`, `data.existing` and `data.details_kept`: false means the reason was retained but the selected text did not fit in full; show a successful partial receipt, not a failure or automatic resend.
1057
+
1015
1058
  ### Idempotency
1016
1059
 
1017
1060
  The REST API (`/api/v1/*`) supports the `Idempotency-Key` header on POST/PUT/PATCH requests. Include a unique key per mutation to prevent duplicate side effects on retries. The key is scoped per-user and cached for 24 hours. Max key length: 256 characters. The SDK's built-in retry handles 429/5xx automatically, but idempotency keys protect against client-side retries too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.123",
3
+ "version": "0.7.195",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",