@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.
- package/README.md +16 -4
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +12 -0
- package/dist/index.d.ts +7 -6
- package/dist/index.js +2 -2
- package/dist/resources/agents.d.ts +70 -0
- package/dist/resources/agents.js +10 -0
- package/dist/resources/curator.d.ts +5 -1
- package/dist/resources/curator.js +6 -2
- package/dist/resources/discovery.d.ts +83 -1
- package/dist/resources/discovery.js +36 -6
- package/dist/resources/e2ee.d.ts +87 -2
- package/dist/resources/e2ee.js +31 -2
- package/dist/resources/email.d.ts +27 -3
- package/dist/resources/email.js +18 -3
- package/dist/resources/env-vars.d.ts +25 -0
- package/dist/resources/env-vars.js +8 -0
- package/dist/resources/moderation.d.ts +22 -0
- package/dist/resources/moderation.js +16 -0
- package/dist/resources/posts.d.ts +1 -1
- package/dist/resources/posts.js +6 -2
- package/dist/resources/protocols.d.ts +209 -2
- package/dist/resources/protocols.js +57 -1
- package/dist/types.d.ts +50 -0
- package/llm.md +16 -5
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|