@recursiv/sdk 0.7.58 → 0.7.62

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
@@ -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
 
@@ -496,10 +498,12 @@ const { data: group } = await r.chat.createGroup({ name: 'Team', member_ids: ['u
496
498
  const { data: msgs } = await r.chat.messages(dm.id, { limit: 50 })
497
499
  ```
498
500
 
499
- Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
501
+ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `addMembers`, `removeMember`, `updateGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
500
502
 
501
503
  Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
502
504
 
505
+ Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group, `request_status`. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
506
+
503
507
  ### r.e2ee — End-to-end encryption transport (flag-gated)
504
508
 
505
509
  Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise), and every later append to it is checked the same way again (both people still accepted, still in the pilot, still only the two of them), so a client need not read the policy before each send; a resend of an event already stored still returns `duplicate: true`, even once a Welcome's target device is gone; blocks apply (`blocked`), to resends too; commits must send `expected_commit_seq`.
@@ -517,7 +521,8 @@ await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope: react
517
521
  const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
518
522
  await r.e2ee.acknowledge(conversationId, { device_id: device.id, seq: page.headSeq });
519
523
  // Doorbell after every append: { conversationId, seq, event? }. `event` (a small application message, as events() returns it) comes only to a socket of the owner's session or an owner key, never an agent-scoped key.
520
- r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event) : controller.sync(conversationId)));
524
+ // The policy is required: policyForConversationType(conversation.type) from @recursiv/e2ee ('direct' or 'group').
525
+ r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event, policy) : controller.sync(conversationId, undefined, policy)));
521
526
  ```
522
527
 
523
528
  Link a new device by scanning (0.7.57; `status()` reports `links: true` when the relay can). The new device N shows the link id in a QR code; an approved device A of the same account scans it. The relay is a short-lived mailbox (180 s, 120 s more once scanned; one open link per sign-in, at most 3 per account; 10 creates per sign-in and 20 per account an hour, refused with 409 `link_limit`, never 429). `approval` and `tag` go only to the sign-in that created the link. Reserved labels get 400 `invalid_label`; a 503 `storage_unavailable` is temporary, so keep polling until `expiresAt`. Own devices carry `createdAt` and `lastSeenAt`.
@@ -534,7 +539,15 @@ const { data: ids, next } = await r.e2ee.conversations({ limit: 200 });
534
539
  r.realtime.onE2eeDevices(({ deviceId, reason }) => {}); // 'packages': it can be claimed now; 'revoked'
535
540
  ```
536
541
 
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.
542
+ 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.
543
+
544
+ 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).
545
+
546
+ ```ts
547
+ const { data: sent } = await r.e2ee.requestResetCode(); // { expires_at, sent_to: 'b***@minds.com' }
548
+ const { data: reset } = await r.e2ee.resetAccount({ email_code, device_name, realm, signing_key, credential, keep_session_token });
549
+ // → { reset, resetAt, retiredRoot, revokedDeviceIds, device (the new main device), signedOut: { sessions, keys, connectedApps } }; a retry after a lost answer gets the same result
550
+ ```
538
551
 
539
552
  ### r.realtime — WebSocket Chat Client
540
553
 
@@ -606,6 +619,8 @@ const { data: results } = await r.posts.search({ query: 'shipped', community_id:
606
619
 
607
620
  Methods: `list`, `get`, `create`, `update`, `delete`, `search`, `replies(id, { limit, cursor })` (after `replies_next_cursor` on `get`), `react`, `unreact`, `liked`, `bookmarked`, `bookmark`, `unbookmark`
608
621
 
622
+ Feed definitions (types only; no route serves one yet): `FeedDefinition` is `{ v: 1, sources, ranking: { mode, range? }, filters?, exclude? }`, with 1-`FEED_SOURCES_MAX` (50) sources read as one union (`FEED_SOURCE_KINDS`; `FEED_SOURCE_KEY[kind]` names each kind's one field, e.g. `{ kind: 'tag', slug: 'rust' }`, `{ kind: 'account', ref: 'did:plc:…' }`). `filters.politics` stores only `'hide'`; viewer settings, blocks, mutes and compass Mix/Sides never go in a definition. Built-in feeds: `builtinCategoryFeedRef('science')` → `'builtin:category:science'`, plus `'builtin:all'` and `'builtin:for_you'`.
623
+
609
624
  `search` ranks relevance x recency x engagement (recency falls as a power of age: this month outranks years ago) through the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as `cursor`: later relevance pages are slices of the ranking the first page kept for ten minutes (nothing skipped or repeated while it lives; after that, the page continues past the last post's score boundary), newest-first pages continue by (created_at, id). A cursor is bound to its search and sort: another search's, or the other sort's, is 400 `invalid_cursor`. The first relevance page carries `latest` (up to three of the newest matches not on the page, one per author, for a Latest strip); the last page carries `fall_over.lanes` (`new`, `open_network`, `tag`): draw a way on, not a wall.
610
625
 
611
626
  ### r.bookmarks — Synced saves (per member, per app)
@@ -681,10 +696,12 @@ const { data: group } = await r.chat.createGroup({ name: 'Team', member_ids: ['u
681
696
  const { data: msgs } = await r.chat.messages(dm.id, { limit: 50 })
682
697
  ```
683
698
 
684
- Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
699
+ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `addMembers`, `removeMember`, `updateGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
685
700
 
686
701
  Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
687
702
 
703
+ Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group, `request_status`. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
704
+
688
705
  ### r.tags
689
706
 
690
707
  ```typescript
@@ -707,7 +724,7 @@ Methods: `list`, `get`, `create`, `delete`
707
724
  - `r.github` — GitHub integration (getRepository, getTree, getFileContent, getCommits, createFromTemplate)
708
725
  - `r.deployments` — Deployment management (prefer `r.projects.deploy()` for new projects)
709
726
  - `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 })`)
727
+ - `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
728
  - `r.webhooks` — Register inbound webhook endpoints: `register()`, `list()`, `delete()`. Webhook events execute handler code in project sandbox.
712
729
  - `r.jobs` — Scheduled cron jobs: `create({ name, cron, handler_code })`, `list()`, `update()`, `delete()`. Handlers execute in project sandbox on schedule.
713
730
  - `r.wallet` — Wallet, MINDS tokens (`getTokenBalance`, `getTokenLedger`; wei stays a string), USD payouts (`getPayouts`, `startPayoutOnboarding`, `payoutLoginLink`)
@@ -732,8 +749,9 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
732
749
  - `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
750
  - 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
751
  - 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? }`.
736
- - 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.
752
+ - 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`.
753
+ - 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.
754
+ - 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.
737
755
  - `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
756
  - 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`.
739
757
  - 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`.
@@ -830,7 +848,7 @@ Pagination: pass `limit` and `offset`. Check `meta.has_more`.
830
848
  | `RateLimitError` | 429 | Rate limit hit (`retryAfter` seconds, `resetAt` Date, `scope`) |
831
849
  | `ConflictError` | 409 | Resource already exists |
832
850
 
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).
851
+ 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
852
 
835
853
  ### Idempotency
836
854
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.58",
3
+ "version": "0.7.62",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",