@recursiv/sdk 0.7.58 → 0.7.59

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.
@@ -1,4 +1,4 @@
1
- import { ValidationError } from '../errors.js';
1
+ import { AuthenticationError, ValidationError } from '../errors.js';
2
2
  /** Canonical protocol operation names returned by the adapter registry. */
3
3
  export const PROTOCOL_CAPABILITY_OPERATIONS = Object.freeze([
4
4
  'discover',
@@ -34,12 +34,43 @@ function hostedIdentityPath(id) {
34
34
  }
35
35
  return `/protocols/identities/${encodeURIComponent(id)}`;
36
36
  }
37
+ /**
38
+ * Whether the item's own card carries a playable video. Use it on a `media: 'video'` read before a Watch-style
39
+ * player: a visitor's read can serve an item whose video the server withheld (its card's `media` is then empty).
40
+ * A video only in a quoted post does not count, as the server never counts a quote's media.
41
+ */
42
+ export function viralItemHasVideo(item) {
43
+ return item.post.media.some(media => media.type === 'video');
44
+ }
37
45
  /** Only the two radar fields travel; caller scope or destination extras never reach the query string. */
38
46
  /** Forwarded only when given, so a default read makes exactly the call it always made. */
39
47
  const readOptions = (options) => options ? [options] : [];
40
48
  function radarQuery(params) {
41
49
  return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) };
42
50
  }
51
+ /** Only the six Viral read fields travel, each only when given: the read answers any other parameter with a 400. */
52
+ function viralQuery(params) {
53
+ const query = {};
54
+ for (const key of ['network', 'category', 'media', 'limit', 'cursor', 'compass'])
55
+ if (params[key] !== undefined)
56
+ query[key] = params[key];
57
+ return query;
58
+ }
59
+ /** An off read is empty whatever else its body carried. Every other status is passed through as served. */
60
+ function viralRead(response) {
61
+ if (response.data?.status !== 'off')
62
+ return response;
63
+ return { ...response, data: { ...response.data, items: [], cursor: null, exhausted: true } };
64
+ }
65
+ /** The server's own off answer (ViralReadService), built here for an API that predates the read. */
66
+ function viralOff(params) {
67
+ return { data: {
68
+ items: [], cursor: null, builtAt: null, status: 'off', window: { primary: '1h', context: '6h' }, baselines: {}, provenance: { providers: [] },
69
+ meta: { network: params.network ?? 'all', category_filter: params.category !== undefined ? { selected: params.category } : null,
70
+ media_filter: params.media ?? null, compass_filter: null, safety_filter: { withheld: 0, pending: 0 } },
71
+ exhausted: true,
72
+ } };
73
+ }
43
74
  /** Forward only the media contract fields; nothing else from the app's own media rows. */
44
75
  const nativeMediaInput = (media) => media.map(item => ({ url: item.url,
45
76
  ...(item.alt !== undefined ? { alt: item.alt } : {}), ...(item.width !== undefined ? { width: item.width } : {}),
@@ -117,6 +148,23 @@ export class ProtocolsResource {
117
148
  getSharedRadarStory(projectId, params) {
118
149
  return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/radar/story`, { cluster: params.cluster });
119
150
  }
151
+ /**
152
+ * The Viral read without a key, for a signed-out visitor: posts whose authors allow wide reach only, and media only
153
+ * where the server cleared it, so a `media: 'video'` read can carry items whose video was withheld (filter with
154
+ * `viralItemHasVideo`). `status: 'off'` (no items, never thrown) until the server enables the read for the app;
155
+ * an API that predates the read answers this path 401 `missing_api_key`, which resolves as off too. Every other
156
+ * failure throws. Cacheable for a minute by a shared cache.
157
+ */
158
+ async getSharedViral(projectId, params = {}, options) {
159
+ try {
160
+ return viralRead(await this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/viral`, viralQuery(params), ...readOptions(options)));
161
+ }
162
+ catch (error) {
163
+ if (error instanceof AuthenticationError && error.code === 'missing_api_key')
164
+ return viralOff(params);
165
+ throw error;
166
+ }
167
+ }
120
168
  publicNetworks() {
121
169
  return this.client.get('/protocols/social/networks');
122
170
  }
@@ -188,6 +236,14 @@ export class ProtocolsResource {
188
236
  getPublicRadarStory(params, options) {
189
237
  return this.client.get('/protocols/social/radar/story', { cluster: params.cluster }, ...readOptions(options));
190
238
  }
239
+ /**
240
+ * The Viral read under the signed-in member's app key (scopes `users:read` and `posts:read`): every network's posts
241
+ * the build keeps, less the member's own blocks, mutes and hidden posts. Never cached. `status: 'off'` resolves with
242
+ * no items; a 401 or 403 always throws (a session or key failure is never reported as off).
243
+ */
244
+ async getPublicViral(params = {}, options) {
245
+ return viralRead(await this.client.get('/protocols/social/viral', viralQuery(params), ...readOptions(options)));
246
+ }
191
247
  /** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
192
248
  getPublicPopularProfiles(params) {
193
249
  return this.client.get('/protocols/social/popular-profiles', { protocol: params.protocol, limit: params.limit, cursor: params.cursor });
package/dist/types.d.ts CHANGED
@@ -54,8 +54,41 @@ export type FalloverStep = {
54
54
  } | {
55
55
  kind: 'all';
56
56
  };
57
+ /**
58
+ * What a Politics choice (`compass`) did to a page, on the feeds that take one (`posts.list` on discovery, category
59
+ * and Minds' own lists, `curator.forYou`). Counts only: never a post's placement, never the cells asked for.
60
+ * `unavailable` means nothing was applied (the filter is not served to this caller yet, or the judgments are not
61
+ * there). Under Choose sides every post counts once as kept or left out; `non_political`, `pending` and `unplaced`
62
+ * (with posts judged maybe) are the not-placed rest. `mix` is Mix sides' band counts over the page served.
63
+ */
64
+ export interface CompassFilterMeta {
65
+ status: 'applied' | 'unavailable';
66
+ mode: 'all' | 'mix' | 'hide' | 'cells';
67
+ kept: number;
68
+ left_out: number;
69
+ unplaced: number;
70
+ pending: number;
71
+ non_political: number;
72
+ uncertain_shown: number;
73
+ truncated: boolean;
74
+ rubric?: string;
75
+ mix?: {
76
+ econ: {
77
+ l: number;
78
+ c: number;
79
+ r: number;
80
+ };
81
+ power: {
82
+ l: number;
83
+ m: number;
84
+ o: number;
85
+ };
86
+ };
87
+ }
57
88
  export interface PaginationMeta {
58
89
  discovery?: DiscoveryLayout;
90
+ /** Present when the request named `compass`: what the Politics choice did to this page. */
91
+ compass_filter?: CompassFilterMeta;
59
92
  scanned?: number;
60
93
  category_filter?: {
61
94
  selected: string;
@@ -348,6 +381,21 @@ export interface ListPostsParams extends PaginationParams {
348
381
  discovery?: boolean;
349
382
  /** Public-only broad category. Requires an enabled app experiment; limit <=25. */
350
383
  category?: string;
384
+ /**
385
+ * A Politics choice on a discovery, category or Minds list read: `all`, `mix`, `hide`, or cells in the canonical
386
+ * order `lo,co,ro,lm,cm,rm,ll,cl,rl`, optionally followed by `np`. Any other spelling is 400 `invalid_compass`; on a
387
+ * profile, group, Following, liked or saved read it is 400 `compass_not_supported`. Mix sides and Choose sides
388
+ * spend the member's hourly budget (429 `compass_rate_limited`). The page says what it did in
389
+ * `meta.compass_filter`. Only Minds posts are filtered.
390
+ */
391
+ compass?: string;
392
+ /**
393
+ * `false` asks once: the first 5xx or 429 is thrown, with no retry and no Retry-After sleep. For a read that holds its
394
+ * own deadline, such as a Discover feed session's Minds read. Never sent to the server. It rides in the params, not
395
+ * in a second argument, so a reader wrapped by a function that forwards only its params (as the Minds app wraps the
396
+ * `posts.list` it hands a feed session, to add a Politics choice) still asks once.
397
+ */
398
+ retry?: boolean;
351
399
  community_id?: string;
352
400
  author_id?: string;
353
401
  organization_id?: string;
@@ -355,6 +403,8 @@ export interface ListPostsParams extends PaginationParams {
355
403
  tag_ids?: string;
356
404
  /** Pass 'me' to return only posts where audience_user_id matches the caller (personal agent feed). */
357
405
  audience_user_id?: 'me' | string;
406
+ /** Return only posts by accounts the authenticated caller follows, newest first (the Following feed). */
407
+ following?: boolean;
358
408
  /** Return only posts the authenticated caller has upvoted. */
359
409
  liked?: boolean;
360
410
  /**
package/llm.md CHANGED
@@ -70,7 +70,9 @@ const { data } = await r.envVars.list({ project_id: 'proj_xxx' })
70
70
  // [{ key: 'STRIPE_SECRET_KEY', value: '••••••••', is_secret: true, ... }]
71
71
  ```
72
72
 
73
- Methods: `list({ project_id, environment? })`, `set({ project_id, key, value, is_secret?, environment? })`, `delete({ project_id, key, environment? })`
73
+ Methods: `list({ project_id, environment? })`, `set({ project_id, key, value, is_secret?, environment? })`, `createProjectApiKey({ project_id, key, scopes?, environment? })`, `delete({ project_id, key, environment? })`
74
+
75
+ `createProjectApiKey` mints a project-bound Recursiv key straight into the env var (e.g. `RECURSIV_API_KEY` for a server-side form) and returns only `api_key_id` and `api_key_prefix`, never the key. Scopes default to `databases:read` + `databases:write`, can't exceed the calling key's, and can't include `admin`. Calling it again rotates: the previous key in that variable is revoked.
74
76
 
75
77
  Keys are UPPER_SNAKE_CASE. `environment` is `'preview' | 'production' | 'both'`, default `'both'`.
76
78
 
@@ -534,7 +536,15 @@ const { data: ids, next } = await r.e2ee.conversations({ limit: 200 });
534
536
  r.realtime.onE2eeDevices(({ deviceId, reason }) => {}); // 'packages': it can be claimed now; 'revoked'
535
537
  ```
536
538
 
537
- Revoking a device other active devices were approved through, or the account's root (main) device, throws a 409 `ConflictError` with code `has_dependents` and `details: { dependents: string[], root: boolean }`; nothing changes (0.7.58). `revokeDevice(id, { cascade: true })` revokes it and every device chained through it, answering `revoked` (ids). Once the root is revoked the account cannot link a new device.
539
+ Revoking a device other active devices were approved through, or the account's root (main) device, throws a 409 `ConflictError` with code `has_dependents` and `details: { dependents: string[], root: boolean }`; nothing changes (0.7.58). `revokeDevice(id, { cascade: true })` revokes it and every device chained through it, answering `revoked` (ids). Once the root is revoked the account can link a new device only after it resets encrypted chat.
540
+
541
+ Reset encrypted chat (D2; only where `status()` reports `accountReset: true`): for someone who lost every device, or who starts over on this one. The code is eight digits, emailed to the account's address; the relay warns the owner. One call then revokes every device, signs the account out of every other session, API key and MCP-connected AI client on the network (closing their sockets), and registers this device as the new main device; contacts see that the security code changed. With `accountReset` on, `revokeDevice` on the main device answers 409 `reset_required`, and so does `createLink` when the main device is gone with nothing left to approve. Errors: 403 `confirmation_failed` (wrong, expired or spent code; nothing changed), 409 `code_limit` (`details.retryAfterSeconds`, never 429), 400 `no_email_on_file`, 503 `email_delivery_unavailable`, 409 `conflict` (name or key used before), 403 `forbidden` (an account that is not this network's own, such as a platform owner's; `status()` never reports `accountReset` to such an account, and its main device and links answer as with the feature off).
542
+
543
+ ```ts
544
+ const { data: sent } = await r.e2ee.requestResetCode(); // { expires_at, sent_to: 'b***@minds.com' }
545
+ const { data: reset } = await r.e2ee.resetAccount({ email_code, device_name, realm, signing_key, credential, keep_session_token });
546
+ // → { reset, resetAt, retiredRoot, revokedDeviceIds, device (the new main device), signedOut: { sessions, keys, connectedApps } }; a retry after a lost answer gets the same result
547
+ ```
538
548
 
539
549
  ### r.realtime — WebSocket Chat Client
540
550
 
@@ -707,7 +717,7 @@ Methods: `list`, `get`, `create`, `delete`
707
717
  - `r.github` — GitHub integration (getRepository, getTree, getFileContent, getCommits, createFromTemplate)
708
718
  - `r.deployments` — Deployment management (prefer `r.projects.deploy()` for new projects)
709
719
  - `r.integrations` — External service integrations and agent tool connections. Connections: `listConnections(orgId)`, `connect()`, `confirmConnection()`. Deterministic tool execution (no agent/LLM in the loop): `executeTool({ connection_id, tool, arguments })` — run a tool yourself with explicit args + pagination and get the raw result back (e.g. `{ tool: 'GMAIL_FETCH_EMAILS', arguments: { query, max_results } }`); use for reliable batch coverage, vs `agents.chat` for autonomous behavior. Tool approvals: `listPendingExecutions(conversationId)`, `approveExecution(id)`, `rejectExecution(id)` — handles both integration tools and brain/platform tools (provision_sandbox etc.); use these to unblock a permission-mode agent waiting on tool approval
710
- - `r.email` — Email campaigns, batch sending, and transactional email (`r.email.send({ to, subject, html })`)
720
+ - `r.email` — Email campaigns, batch sending, and transactional email. **Pass `category: 'transactional'`** for receipts, invites, confirmations and alerts: `r.email.send({ to, subject, html, category: 'transactional' })`. Without it the send is treated as marketing and held for anyone who has not given marketing consent. A send where every recipient was held throws `EmailNotSentError` (its message carries the reason). A `status: 'partial'` result lists each recipient's `status` and `reason` in `messages`.
711
721
  - `r.webhooks` — Register inbound webhook endpoints: `register()`, `list()`, `delete()`. Webhook events execute handler code in project sandbox.
712
722
  - `r.jobs` — Scheduled cron jobs: `create({ name, cron, handler_code })`, `list()`, `update()`, `delete()`. Handlers execute in project sandbox on schedule.
713
723
  - `r.wallet` — Wallet, MINDS tokens (`getTokenBalance`, `getTokenLedger`; wei stays a string), USD payouts (`getPayouts`, `startPayoutOnboarding`, `payoutLoginLink`)
@@ -732,7 +742,8 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
732
742
  - `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
733
743
  - 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.
734
744
  - 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.
735
- - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`.
745
+ - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. 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`.
746
+ - 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.
736
747
  - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor? })`; render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
737
748
  - `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.
738
749
  - 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`.
@@ -830,7 +841,7 @@ Pagination: pass `limit` and `offset`. Check `meta.has_more`.
830
841
  | `RateLimitError` | 429 | Rate limit hit (`retryAfter` seconds, `resetAt` Date, `scope`) |
831
842
  | `ConflictError` | 409 | Resource already exists |
832
843
 
833
- 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. 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).
844
+ 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).
834
845
 
835
846
  ### Idempotency
836
847
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.58",
3
+ "version": "0.7.59",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",