![openvibe-sdk](https://openvibe.services/shared/social/services.jpg) # OpenVibe.SDK **Open source JavaScript/TypeScript SDK and API clients for the OpenVibe network** — docs at [openvibe.services](https://openvibe.services). **Docs:** https://openvibe.services · **For AI:** https://openvibe.services/llms.txt **Install and test:** `npm ci && npm test` (Node 20 or newer) ```bash npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0 ``` > Supported browser and server clients for the OpenVibe platform. **Status:** alpha, v0.164.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses. **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30. **License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0. **If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here. After v0.97.1 is tagged: ```bash npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0 ``` It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against). ## Purpose The one supported way to call OpenVibe from an app, first-party or not: clients for each public capability, token handling, discovery and version checks, webhook verification, the outbox and inbox services use, the per-actor limiter, the OpenVibe Frame, and a mock platform to test against. ## Quick start ### Browser app (signed-in user) ```js import { createClient } from 'openvibe-sdk/core'; import { startAuthorization, readCallback } from 'openvibe-sdk/auth'; // browser build: PKCE only import { createCommunityClient } from 'openvibe-sdk/community'; import { subscribe } from 'openvibe-sdk/realtime'; // Sign in: send the browser to the Network with a PKCE challenge. const { url, state, codeVerifier } = await startAuthorization({ clientId: 'my-app', redirectUri: 'https://my.app/auth/callback' }); sessionStorage.setItem('ov_pkce', JSON.stringify({ state, codeVerifier })); location.assign(url); // On /auth/callback: check state, then hand the code and verifier to YOUR server (see below). const saved = JSON.parse(sessionStorage.getItem('ov_pkce')); const { code } = readCallback(location.href, { expectedState: saved.state }); await fetch('/auth/exchange', { method: 'POST', body: JSON.stringify({ code, codeVerifier: saved.codeVerifier }) }); // Call services as the signed-in person (Network session cookie). const client = createClient({ credentials: 'include' }); const community = createCommunityClient(client); const { pastes } = await community.pastes.list({ limit: 20 }); // Live updates, resumable across reloads. const sub = subscribe(['live.stream.*'], (event, { cursor }) => { render(event); localStorage.setItem('cursor', cursor); }, { client, lastEventId: localStorage.getItem('cursor'), onGap: () => refetchEverything(), }); ``` **The token exchange happens on your server.** First-party clients and confidential developer apps have a client secret, and a secret must never reach a browser. A public developer app has no secret, but its server should still do the exchange and keep the token in an HttpOnly session. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach (and the browser bundle itself) and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`. ### Server service (service principal) ```js const { createClient } = require('openvibe-sdk/core'); const { createServiceTokenClient, exchangeCode, verifyUserToken } = require('openvibe-sdk/auth'); const { createEventsClient } = require('openvibe-sdk/events'); const { createIdentityClient } = require('openvibe-sdk/identity'); const tokens = createServiceTokenClient({ clientId: 'live', clientSecret: process.env.OV_CLIENT_SECRET }); const client = createClient({ tokenProvider: tokens, // one cached token per audience (openvibe.events, openvibe.media, …) baseUrls: { network: 'http://127.0.0.1:4000', events: 'http://127.0.0.1:4300' }, // same-host services; others come from discovery }); // Continue the incoming request's trace on every outbound call. app.use((req, _res, next) => { req.ov = client.fromRequest(req); next(); }); const events = createEventsClient(client, { source: 'live' }); await events.publish({ event_type: 'live.stream.started', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '12' } }); const who = await createIdentityClient(client).resolve({ system: 'live', id: 123 }); // -> { subject, username, … } | null // The browser's /auth/exchange from above: const t = await exchangeCode({ code, codeVerifier, redirectUri, clientId: 'my-app', clientSecret: process.env.OV_OAUTH_SECRET }); const claims = await verifyUserToken(t.access_token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.network' }); claims.subject_id; // usr_… ``` ### External developer (a developer app) Developer apps belong to a project on OpenVibe.Network ([developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md)). An app is a principal `app_`: `confidential` (has a secret, may use client credentials) or `public` (no secret, authorization code + PKCE only), in `sandbox` or `production`. Its tokens last 5 minutes, carry one audience, the approved capabilities (`cap`), `project_id`, `env` and `ns: [project_id]`, and there are no refresh tokens. A server app with a secret and a grant (here `media.object.upload`) uploads into its project's Media namespace: ```js const sdk = require('openvibe-sdk'); const tokens = sdk.auth.createServiceTokenClient({ clientId: process.env.OV_CLIENT_ID, clientSecret: process.env.OV_CLIENT_SECRET }); const client = sdk.createClient({ tokenProvider: tokens }); await client.discover(); // origins + contracts version from https://openvibe.network/.well-known/openvibe const { scope, unverifiedClaims } = await tokens.getTokenInfo({ audience: 'openvibe.media' }); // what Network granted (display only) const media = sdk.media.createMediaClient(client, { app: unverifiedClaims.project_id }); // Media tenancy is the project id const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.png', contentType: 'image/png' }); file.public_url; // https://openvibe.media/f/ ``` For large files, use Media's resumable multipart object API. The uploader uses 8 MiB parts, three concurrent requests, retries missing parts, and exposes the upload reference before any bytes leave so the app can persist it. Keep the credential on a server. ```js const uploader = sdk.mediaUpload.createUploadClient({ app: unverifiedClaims.project_id, tokenClient: tokens }); const object = await uploader.upload(file, { onSession: ({ objectId, uploadId }) => saveUploadReference({ objectId, uploadId }), onProgress: ({ uploadedBytes, totalBytes }) => reportProgress(uploadedBytes, totalBytes), }); // After a restart: uploader.resume(savedReference, sameFile, { onProgress }); ``` On a page that loads the pinned `ov-upload.js` and `ov-upload.css` from `openvibe-shared`, `sdk.ui.progress(el)` mounts the same progress component used across OpenVibe sites. The token's `env` picks the tenant: a sandbox app's files go to the project's sandbox tenant (`app_id: '-sandbox'`) and are never served publicly. Media answers them with `sandbox: true`, a signed, expiring `url` and `url_expires_at`; the client adds `signed_url` (the same URL) and sets `public_url` to `null`. Uploading and deleting need `media.object.upload`, listing and getting `media.object.read`. App events (`events.app.publish | read | subscribe`) live under the project's own topic `app..*` (`project_key` = `p` + the lowercased project ULID) with `source: app-`. `createAppEvents()` fills that in, and scopes pulls and subscriptions to the project: ```js const { createAppEvents } = require('openvibe-sdk/events'); const events = createAppEvents(client, { projectId: unverifiedClaims.project_id, appId: process.env.OV_CLIENT_ID }); await events.publish({ event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload: { n: 1 } }); // -> app.p01k5….order.shipped, source app-01k5…, actor { type: 'app', id: 'app_…' } (onBehalfOf: the person instead) const page = await events.pull({ topic: 'order.*', platformTopics: ['live.stream.*'], after: saved }); // + public first-party events; save page.next_cursor await events.subscribe({ topicPattern: '*', endpoint: 'https://hooks.example.com/openvibe' }); // public https only ``` Events keeps sandbox and production apart: a sandbox app sees and receives only its project's sandbox events (and public first-party ones), a production app only production ones. Realtime (SSE) never streams app events; pull or subscribe. `projectKey(projectId)` and `appSource(appId)` are exported for code that builds envelopes itself. "Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`). ```js const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('openvibe-sdk/auth'); // /login: capability ids as scope, and the audience the token is for. const { url, state, codeVerifier } = await startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] }); // /callback: const { code } = readCallback(req.url, { expectedState: state }); const t = await exchangeCode({ clientId, clientSecret /* omit for a public app */, code, codeVerifier, redirectUri, audience: 'openvibe.media' }); t.refresh_token; // undefined: when it expires, the person signs in again // A service receiving app tokens verifies them offline: const claims = await verifyAppToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.media' }); claims.project_id; claims.env; claims.on_behalf_of; // env=sandbox is refused (token.sandbox_refused) unless acceptSandbox: true ``` Manage projects, apps, secrets and grants with `openvibe-sdk/projects` and a Network user token: ```js const projects = require('openvibe-sdk/projects').createProjectsClient(sdk.createClient({ token: userAccessToken })); const prj = await projects.create({ name: 'My app' }); const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential' }); app.credential.client_secret; // shown ONCE: store it now await projects.grants.request(prj.id, app.id, 'media.object.upload'); ``` Run a tool, or a Tools job, and follow it across disconnects and restarts (see [Tools](#tools)): ```js const tools = require('openvibe-sdk/tools').createToolsClient(client); const done = await (await tools.run('webp', {}, { files: [{ name: 'a.png', data: bytes }] })).wait(); const jobs = tools.jobs; // or createJobsClient(client): the gateway's /api/v1/jobs facade (Tools S6) const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' }, files: [{ name: 'a.png', data: bytes }], idempotencyKey }); for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.id); // reconnects with Last-Event-ID const res = await jobs.file(job.id, 0); // raw Response ``` Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client at the satellite that runs the type: `createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' })` (img, audio or docs). `test/testing.test.js`, `test/apps.test.js`, `test/app-events.test.js`, `test/mock-services.test.js`, `test/projects.test.js`, `test/jobs.test.js` and `test/tools.test.js` run these flows end to end against `openvibe-sdk/testing`. What the live platform allows today (sandbox audiences, allowances, which capabilities are grantable) is in the Network doc above and in OpenVibe.Examples' README. ## API | Subpath | Where | What | |---|---|---| | `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` | | `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `createNetworkKeys()`, `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` | | `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` | | `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})`; `createAccountEmailsClient({getToken})` manages the signed-in person's verified addresses ([guide](docs/identity-emails.md)) | | `openvibe-sdk/trust` | both | `createTrustClient({networkUrl, getToken})`: `me()` reads the caller's own account or guest signals; `of(subject)` reads an account or guest with `identity.trust.read`; `isNew(signals, {hours})`, `atLeast(signals, band)`, `AGE_BANDS` | | `openvibe-sdk/user-card` | both | `createUserCardClient(client)`: `get(username)` reads the public Network aggregate (display name, avatar, level, streak, badges, followers, active sites). No credentials; hidden or unavailable fields stay absent ([guide](docs/user-card.md)) | | `openvibe-sdk/notifications` | server | `createNotificationsClient(client)`: `push({subjectId \| userId, type, title, message, url, category, priority, …})` through Network (`network.notifications.push`); `{ sent, skipped }` or `{ sent: false, reason: 'unknown_subject' }`; attempted once, the caller retries | | `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services | | `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app..*`), `projectKey`, `appSource`; `createOutbox`, `createInbox`, `createPgOutbox`, `createPgInbox`; `createServiceOutbox({db, source, eventsUrl, clientId, clientSecret, eventTypes?, validate?})` (a service's outbox in one call: `emit`/`emitIn` inside the change's transaction, `moderationAction`, `start`/`stop`/`kick`/`status`) | | `openvibe-sdk/sso` | server | `createSsoClient({site, baseUrl, clientId, clientSecret, networkUrl, networkInternalUrl, secureCookies})`: a product site's sign-in with OpenVibe.Network: `router(express)` (`/login` with state + PKCE S256, `/callback`, `/fedcm`, `/logout`, `/me`, `/refresh`), `optionalAuth()`, `requireAuth()`, `verify(token)` (offline, shared JWKS), `extractToken(req)`; `sanitizeNext`, `COOKIES` | | `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` | | `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted | | `openvibe-sdk/composer` | both | `createComposerClient(client, {mediaBaseUrl?, networkBaseUrl?})`: `attachments.upload(file, {onProgress, signal})` (multipart to Media through `XMLHttpRequest`, real browser progress), `attachments.remove(id)`, `composer.prefs.get()/put(prefs, rev)` (a stale revision is 409 `composer.rev_conflict`), `mentions.search(q, kinds)` | | `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, shared `comments.resolve/get/upload/create/edit/delete/visibility`, `as(subject)` | | `openvibe-sdk/history` | both | Private history reads: cursor pages, title search, day/week summaries, recent agent reads, settings and owner-scoped deletes. | | `openvibe-sdk/home-feed` | both | `createHomeFeedClient(client)` gives `ov.home.get()/set(layout)`, `ov.network.today()`, `ov.feed.list({ for: 'me', cursor })`, `ov.feed.post(text)`, and `ov.feed.contribute({ id, text, url })`. Personal calls need a user Bearer token; contribution needs an app token granted `community.pulse.contribute`. Today is public and cached by Network for five minutes. | | `openvibe-sdk/feed` | both | `createFeedClient(client)` gives `ov.feed.list({ kind, cursor })` for the public cross-site Community Feed. Pass the returned `next_cursor` as `cursor` to read the next page. | | `openvibe-sdk/views` | both | `createViewsClient(client)`: `record(event \| [events])` (up to 20 per call) sends a preview, watch or live heartbeat for `{ service, kind, id }` to `POST /api/v1/views`; `counts(ref)` reads `{ views, engaged, previews, preview_share, peak_concurrent }`. A signed-out viewer keeps an opaque session id; the service stores no IP or user agent and counts a preview from 1 s. | | `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `embedUrl(robotId, mode)` (panel, overlay or compact), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) | | `openvibe-sdk/themes` | both | `createThemesClient(client)`: `catalog({mode, search, sort, limit})` (seasonal themes carry `season`), `get(idOrSlug)` (null when missing), `export(idOrSlug)`, `active()` (`{theme_id, custom_variables, display}`), `set(themeId, {custom})`, `setDisplay({motion, text, seasonal})`, `seasons({date})`, `submissions()`, `submit(theme)`, `import(file)`. In the browser: `followTheme(fn)` calls back now and on every theme, display or season change with `readTheme()` (`{id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}`) and returns `stop()`, for a canvas or game to keep up; `applyTheme(vars, {root})`, `seasonOn(date, seasons)`, `onColor(hex)`, `DISPLAY_OPTIONS`, `TOKENS` | | `openvibe-sdk/bench` | both | `createBenchClient(client, {actingSubject?})`: `kinds.list/get`, `tasks.create/get`, `rounds.create/get`, `entries.submit`, `judgements.next/cast`, `boards.get`, `ratings.get` (public, at least twenty games), `models.get`, `datasets.get/iterate`, `evals.create/get/run/results`, `mcpTools()`. Audience `openvibe.bench`; service id `bench`. | | `openvibe-sdk/vision` | both | `createVisionClient(client)`: image questions, OCR, private search, network tools, UI checks, consent and face privacy, and `/mcp`. Audience `openvibe.vision`; service id `vision`. | | `openvibe-sdk/consent` | server | `createConsent({networkUrl, token, ttlMs?})`: the L24 check before any use of a person's content or likeness for AI or data. `allowed(subject, purpose, {kind, client, feature})` → boolean, `filter(subjects, purpose, opts)` → the allowed subset (batches of 500, input order), `onChange(event)` drops that subject's answers for the purpose on `network.consent.changed`, `mock(map)` for tests. **Fails closed** (network error, 2 s timeout, non-200 or malformed answer → false); caches 60 s; logs at most once a minute. [Example](docs/consent.md) | | `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible), `code(text, opts)` for merged code results or `{deep: true}` for a cited answer, `codeIterate(text, opts)` | | `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` | | `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id; telemetry is covered once here (the person's `telemetry_samples` exported as `telemetry.json`, its `subject` set to NULL and the sample kept) unless the table is listed; `uncovered(db, {tables, taskKeyed?, exempt?})` lists the tables with a person column (`subject`, `owner`, `created_by`, `user_id`, `person`) that nothing covers, for a service test to assert is empty | | `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `activity.*` (list, unread, readOne, read: what happened to the person in Chat), `embeds(message)` (the typed `chat.embed@1` address previews on a message), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) | | `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `creations()` (the public catalogue of what AI can make, no token sent), `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. | | `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade | | `openvibe-sdk/agents/loop` | both | The light agent loop (a port of pi's, MIT): `runLoop({ model, tools, system, messages, prompts, budget, beforeToolCall, afterToolCall, transformContext, steering, followUp, finishTurn, onEvent, signal })` resolves `{ messages, stop, usage, cost, turns }` (stop: done, terminated, budget, turns, aborted, error). Tools in parallel (results in the model's order) or in sequence; an unknown tool, refused input, a throw or a timeout comes back to the model as an error; a call cut off by the output limit never runs; repeats get a reminder; idempotent tools answer from a memo; long outputs are clipped. `createStepSink({ append, update, delta, startSeq, startTurn })` turns its events into `agents.step@1` steps | | `openvibe-sdk/agents/dispatch` | both | The Dispatcher, as pure functions over a person's choices: `ladder(choices, { need, pin, avoid, budgetLeft })` (their own machine, a signed-in plan, their key, the free allowance, then credits, cheapest first; what cannot do the step is listed in `.skipped` with why; a person's pick wins when it can), `pick`, `escalate(current, evidence, choices)` (only on a failed test, schema, tool or grader, or the model's own "not sure"), `harnessFor(task, harnesses, { plans })`, `placeFor(run, { machines, cloud })`, `estimate(choice, tokens)`, `slice(parentBudget, share)` | | `openvibe-sdk/agents/blueprint` | both | Make your own agent (agents.blueprint@1, ten parts): `validate(doc)` (the shape and the rules a schema cannot say: no secret in it, every `{{input}}` declared, level full has a budget, machine and robot tools have somewhere to run, a served tool has its shapes, a team never contains itself), `withDefaults` (talks only, asks first, keeps nothing), `asks(doc, { describe })` (the install screen in words, with the keys `agents.install@1` records), `wider(old, new)` (what a new version asks for that the old did not), `unagreed(doc, agreed)`, `fill(doc, inputs)`, `compile(doc, { install, inputs, text, tryIt })` (run-start, the loop's system and tools, the Dispatcher's need, places, memory, team; Try it asks before every tool and spends at most 5¢), `estimate(doc, choices)` (the price of a run and where it is free), `ref` / `parseRef` | | `openvibe-sdk/agents/rules` | both | Permissions as data (agents.permission-rule@1): `decide(rules, call, { attended, level })` (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), `fromBlueprint(doc)` (only the tools it names, never `.git/hooks` or its own config, its sandbox and network), `matches` | | `openvibe-sdk/agents/router` | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): `question(step)`, `probability(answer)`, `route({ p, threshold, small, strong })`, `calibrate(history, { strongShare })` (the threshold that sends that share strong), `outcomes(history)` (the quality-cost points), `chain(step, { pick, pin, ask })` | | `openvibe-sdk/agents/metrics` | both | What a run measured (agents.run@1 1.4.0 metrics): `createRecorder()` (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), `passAtK`, `passHatK`, `flakeRate` | | `openvibe-sdk/ai-meter` | both | Every product meters its AI calls the same way (ai.call.recorded@1 1.1.0, ai.call-report@1): `startCall({ system, provider, model, run_id|turn_id, operation, task, task_id, harness, place })` → `queued(ms)`, `firstToken()`, `tool()`, `end({ usage, outcome, charged_usd, fallbacks, error_class })` (TTFT, latency, tokens per second after the first token); `normalize(usage)` (OpenAI chat and Responses, Anthropic, Gemini, Ollama; the uncached input); `createMeterClient({ aiUrl, tokenClient })` (batches to AI's POST /api/v1/usage/calls, retries what can be retried, never resends a refused batch) | | `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) | | `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request | | `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) | | `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` | | `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) | | `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) | | `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) | | `openvibe-sdk/placement` | server | `plan(requirements, offers, {rateCards, states, now, current, minGain, weights, success})` → `platform.placement-result@1`; `place(requirements, offers, {…plan, person, step, floor, priors, outcomes, rules, names})` → `{result, line, ranked, rejected, fallback}`: the person's rules and ownership, the quality bar, cost per success and one sentence saying why; `optionKey`, `learn`, `successOf`, `escalate`, `lineFor`; `TRUST_CLASSES`, `TRUST_ORDER`, `DEFAULT_TRUST`; `planAi(requirements, deployments, {enabled, usage, evidence, rates, current})` → `{chosen, rejected, objective, estimate, explain}`: the AI Exchange face of the same planner (hard gates, the cost per success in nano-USD, sparse evidence marked), off by default ([details](docs/placement.md)) | | `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token | | `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync --to `, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) | | `openvibe-sdk/quest` | server | `createQuestClient(client)`: `daily()`, `myDaily()`, `today()` (authenticated current-day response), `stepTypes()`, `contributeStepType(step)` for the shared daily quest and reviewable event-backed steps (`quest.steps.contribute`); `cardData({ token, timeZone })` for the shared quest card (today's quest, the catalog kept ten minutes, and the person's badges/quests), with the person's Network token; `appQuests.define/list/retire` for an app's own badge quests (`quest.app_quest.manage`); `eventType(projectId, name)` builds an app event topic. App quests pay badges only; production events count | | `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried | | `openvibe-sdk/streams` | server | `createStreamsClient(client, {baseUrl?})`: an agent's own stream on OpenVibe.Live (the Agent Streams plan, S1; `live.agent-stream@1`, an `ags_…` id with no stream key and no slot). `startAgentStream({title, task, visibility, allowSites})` → `{stream, publish}` (the WHIP url and the one-time `wpt_…` ticket with its expiry), `renewTicket(id)` and `revokeTicket(id)` (the kill switch: Live ends the WHIP session at once), `state(id, {ticket})` (what the desk polls: viewers, `encode`, the curtain, `ends_at`), `postFrame(id, bytes, {ticket, contentType})` (the last frame while Live wants no video, JPEG or WebP at most 200 KB), `setCurtain(id, up, reason)`, `setVisibility(id, v)`, `end(id)` and `viewTicket(id)`. The publish ticket is a bearer of its own on the desk routes; a bad id, visibility, curtain reason or an oversized frame is refused before anything leaves. [Docs](docs/streams.md) | | `openvibe-sdk/inventory` | server | `createInventoryClient(client)`: OpenVibe.Inventory, the network-wide inventory (ADR-054). Public reads: `kinds.list/get`, `definitions.list({kind, issuer})/get`, `people.items(subject)`, `people.holdings(subject, opts)`, `people.holdingsCount(subject, opts)`, `people.holdingPages(subject, opts)`, `people.equipped(subject)`, `equipped([subjects])` (≤ 100, for chat renders). The person's own: `me.items()`, `me.equip({kind, slot, instance_id | null})`. An issuer's: `definitions.create/update`, `grants.create({definition_id, subject, idempotency_key})` → `{ instance, created }` (retry-safe on the key), `instances.consume/revoke`. `forPerson(usr_…)` sends `X-OV-Subject` for a service acting for a person. Audience `openvibe.inventory` | | `openvibe-sdk/tips` | server | `createTipsClient(client)`: OpenVibe.Tips' `/api/v1`. A creator's page: `profiles.get/update/totals/sounds/supporters`. Tipping: `tip({creator, amount, message?, goal_id?, pay_with})` → `{ interaction, checkout_url, checkout_ref }`, `send.message/tts/media/sound`. Receipts: `interactions.get/list/recordExternal/hide/restore`. `goals.list/get/create/update/close`, `moderation.queue/log/moderators.*`, `overlays.tokens.*/configs.*/simulate`, `me.export/erase`. Every write carries an Idempotency-Key, which Tips replays once, so retries are safe. Audience `openvibe.tips` | | `openvibe-sdk/blog` | server | `createBlogClient(client)`: OpenVibe.Blog for the person who authorized the app. `blogs.get/posts(handle, {limit, offset, all})/feed` (JSON Feed), `changelog({service, kind, area, q, limit, before})` (the network changelog's `blog.changelog-entry@2` rows), `posts.create(handle, {title, body, summary, visibility, …})` → `{ post, revision }`, `posts.get/update(id, {…, expected_revision})/publish(id, {revision})/unpublish/schedule(id, {at, revision, action})/cancelSchedule/revert/remove/revisions/revision(id, n)`. Public capabilities since openvibe-contracts 0.206.0; publish, unpublish, schedule and delete are sensitive. Creating and reverting are never retried on their own. Audience `openvibe.blog` | | `openvibe-sdk/reviews` | server | `createReviewsClient(client)`: OpenVibe.Reviews. `resolve({name, url, gtin, sku, mpn, source, external})` (a match or candidates; a name alone never matches), `entities.list({q, limit, offset})/get(ref)/history/aggregate/summaryRevision(ref, n)`, `corrections.submit(ref, {body, target_type, target_id, evidence_url})` (to the editors' queue for the person, never retried on its own). Public capabilities since openvibe-contracts 0.207.0. Audience `openvibe.reviews` | | `openvibe-sdk/wiki` | server | `createWikiClient(client)`: OpenVibe.Wiki's reads. `search(q, {space, limit})`, `spaces.list/get(space)`, `pages.get(id, {revision})/revisions(id, {before, limit})/revision(id, n)/diff(id, {from, to, mode})/citations(id, n)`: what the person may read (without one, public pages). Public capabilities since openvibe-contracts 0.207.0. Audience `openvibe.wiki` | | `openvibe-sdk/coupons` | server | `createCouponsClient(client)`: OpenVibe.Coupons. `merchants.resolve(host)/get(id)/coupons(id)` and `get(cpn_…)` (public data), `submit({code, title, merchant_id \| host \| url, evidence_url, …})` (sensitive; never retried on its own) and `report(cpn_…, {outcome, reason})` for the person. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.coupons` | | `openvibe-sdk/deals` | server | `createDealsClient(client)`: OpenVibe.Deals. Public: `offers.list({sort, page})/get/hotness`, `products.get`, `stores.get`. For the person: `offers.submit` (sensitive), `update`, `observe` (a new price observation), `vote(id, 1 \| -1)` (sensitive), `unvote`, `flag(id, {kind, reason})`, `watches.list/create/remove`. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.deals` | | `openvibe-sdk/trade` | server | `createTradeClient(client)`: OpenVibe.Trade. `instruments.resolve(q)/list/get/observations(symbol, {metric, before, limit})/documents`, `sources()`; the person's `watchlists.list/get/create/rename/add/removeItem/remove` and `alerts.list/deliveries/create/remove`. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.trade` | | `openvibe-sdk/space` | server | `createSpaceClient(client)`: OpenVibe.Space, static sites for apps and CI. `deploys.uploadArchive(site, tarGz, {activate, preview, root})`, `uploadFiles(site, [{path, data}], …)`, `uploadSourceBuild(site, archive, {ref, commit_sha})`, `list/get/log/activate/rollback(site, {deploy_id})/remove`; `projects.list/create/get/remove/quota/sites/members.set(p, principal, role)/remove`; `sites.create/get/remove`, `sites.config.get/set/reset` (`{ headers, redirects, spa }`), `sites.source.get/set/remove`; `domains.list/add/verify/remove`. An app reaches only the projects a person added it to (deployer, maintainer or owner). Public capabilities since openvibe-contracts 0.209.0. Audience `openvibe.space` | | `openvibe-sdk/host` | server | `createHostClient(client)`: OpenVibe.Host's public hosting catalog, sent without a token. `providers.list/get(slug)`, `plans.list({kind, min_vcpu, min_ram_mb, min_disk_gb, max_price, currency, location, provider, sort, cursor})/get(id)`, `promos.list`, `offers.list({sort, currency, max_monthly, source, priced, cursor})`, `domains.list/tld('dev')/check('example.dev')`, `benchmarks.list/get`. Any other filter is refused (422). Editing the catalog is first-party | | `openvibe-sdk/redact` | any | The shared secret scrubber (Memory M2): `redact(text)` → `{ text, count }` removes the tool and service keys, PEM private key blocks (including one with no END line), authorization headers, bearer tokens, chat webhooks, connection-string passwords, credential-named assignments and Luhn card numbers, keeping the words around them (`password=[redacted]`); `onlySecret(text)` says an item was nothing but a secret. The same shapes and corpus as OpenVibe Link's `internal/memory/redact.go`, so Link and AI cannot drift | | `openvibe-sdk/explicit` | any | `isExplicit(...texts)`: true when AI text says a scene shows sexual content (body parts, sex acts, pornography, nudity); such a moment, clip or paste is not published | | `openvibe-sdk/no-looks` | any | **Deprecated (2026-10-31):** AI text may describe people. `noLooks`, `noLooksTags` and `hasLooks` return what they are given; `isExplicit` is re-exported from `openvibe-sdk/explicit` | | `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page | | `openvibe-sdk/news` | server | `createNewsClient(client)`: OpenVibe.News' published stories, sent without a token. `topics()`, `stories.list({topic, limit, offset})` (published and retracted, newest first) and `stories.get(id)` (sources, timeline, perspectives). Writing and the editorial reads stay first-party | | `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` | | `openvibe-sdk/chat-apps` | server | The three chat apps (Telegram, Discord, Slack) in one place: `verifyTelegram()`, `verifySlack()`, `verifyDiscord()`, `verifyDelivery()` (the delivery's own proof), `signCallback()` / `verifyCallback()` (a button callback: signed once, used once, expiring), `parseTelegramUpdate()`, `parseDiscordInteraction()`, `parseSlackEvent()`, `parseSlackInteraction()` (each app's payload as the same shape: who said it, where, whether it was said to the bot) and `splitText()` (an answer split the app's way) | | `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` | | `openvibe-sdk/menu` | server | `menuCard({id, site, title, kind, items, more, origin, ttlSeconds, updatedAt})`: one validated `frame.menu-card@1` — plain text cut to the contract's lengths, at most six items (the contract's cap), https links on the site's own origin or an `openvibe.*` host, an optional item `image` (`{src, w, h, alt}`, a picture from elsewhere is dropped), and `more` (`{label, url}`). `menuRoute(getCards, {maxAgeSeconds = 300, origin, log})`: the Express/node:http `GET /.well-known/openvibe-menu.json` handler, answering an array of at most three cards with `Cache-Control: public, max-age=…` and CORS `*`; a `getCards()` failure is `[]` (never a 500) and logs once. `MENU_PATH` | | `openvibe-sdk/run` | server | `createRunClient(client, {project?})`: OpenVibe.Run, jobs on worker nodes. `jobs.submit({class, artifact, args, limits, …})` (Idempotency-Key, so retried safely) → `{ created, job }`, `jobs.list({state, limit, cursor})`, `jobs.get/cancel`, `jobs.events(id, {lastEventId})` (SSE until `end`), `jobs.streamTicket(id)` (a one-use URL for a browser's EventSource); `forProject(prj_…)` sends X-OV-Project. Audience `openvibe.run` | | `openvibe-sdk/video` | server | `createVideoClient(client)`: OpenVibe.Video's playlists of recordings and clips. `playlists.list/create/get/update/remove`, `playlists.items.add/reorder/remove` (`{ kind: 'vod' \| 'clip', id }`), `users.playlists(username)` (public). Private playlists are 404 to everyone but their owner. Audience `openvibe.download` (the media hub's) | | `openvibe-sdk/work` | server | `createWorkClient(client)`: OpenVibe.Work. Public: `jobs.search({q, remote, type, location, cursor})`, `jobs.get`, `sources()`. A person's: `savedSearches.list/create/remove`, `applications.list/create/get/update/remove` (also an app with `work.application.manage`; tracking a listing twice answers the first), `applications.assist(id, {ask})` (their Actor agent helps). Audience `openvibe.work` | | `openvibe-sdk/zone` | server | `createZoneClient(client)`: OpenVibe.Zone, a project's object zones in Media. `zones.create(prj_…, {env, name, description, quota_bytes})` (Idempotency-Key), `zones.list(prj_…, {env, include_deleted})`, `zones.get/update/remove(zon_…, {recursive})`, `zones.usage(zon_…)`. Project members and the project's apps only. Audience `openvibe.zone` | | `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` | | `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it | | `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily | | `openvibe-sdk/vibes` | server | `createVibesClient({appToken \| tokenProvider, billingUrl, fetchImpl?, timeoutMs?})`: Vibes Pay for developer apps (D12). `charges.create({amount, description, reference, return_url})` sends a whole number of Vibes with the reference as the `Idempotency-Key`, → `{id, pay_url, status, expires_at}`; `charges.get(id)`, `charges.refund(id, {reason?})`. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=,v2=.">`, constant time, replay window) and the legacy `sha256=` body-only form; also exported as `verifyWebhook`. A float, zero or negative amount is refused before any request; problem+json becomes `VibesError` (`status`, `code`, `body`, `retryable`), a 401 invalidates a token provider and retries once with the same key | | `openvibe-sdk/exchange` | server | `createExchangeClient({developerKey, baseUrl?, networkUrl?, siteUrl?, fetchImpl?, timeoutMs?, maxRetries?, owner?, paths?})`: OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, item 7). Buyer: `chat(body, {signal})` → the OpenAI shape (`stream: true` answers an async iterable of chunks), `models()`, `offers(model)` (the offers that serve one model, with the price, the speed and the uptime), `catalog()`, `presets.list/create/update/remove` (a preset is stored under its name; there is no PATCH), `usage({days})` (the public aggregate; with `from`/`to`/`requester` the platform usage for that requester) and `receipt(id)`. Seller: `seller.offer({model, price, hours, concurrency, endpoint, machineId, owner})` registers the offer in the Network’s user-owned AI offers (the same registry OpenVibe Link’s "Offer to the network" writes to: `POST /internal/ai/offers/report`, the capability `network.resource.report`), `seller.earnings()`, `seller.verification()` (canaries, spot re-runs, reputation, uptime) and `seller.offers()`. A 429 or a 5xx is retried with a jittered backoff (a write only when it is idempotent); every call takes an AbortSignal. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=,v2=.">`, constant time, replay window) and the legacy `sha256=` body-only form; also exported as `verifyWebhook`. Compute: `computePrices({kind: 'cpu' | 'gpu'})` reads the reviewed GPU/computer tariffs (per hour, per second, checked and review dates, the provider's price page) from Exchange's public site, keyless; `createComputePriceClient({baseUrl?, fetchImpl?})` is that reader alone | | `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) | | `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card; the AI Exchange money math in integer nano-USD with `nanoUsd()`, `chargeTokens()`, `expectedCostNano()`, `costPerSuccessNano()`, `formatNanoUsd()` | | `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes | | `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` | | `openvibe-sdk/delivery` | server | `deliveryOffer()` and `validateDeliveryOffer()` for `platform.delivery-offer@1` | | `openvibe-sdk/link` | server | `linkCapabilities()` and `validateLinkCapabilities()` for `platform.link-capabilities@1`; `linkOffers()` derives resource and runtime offers from a node descriptor | | `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; the HTTP telemetry kit: `createHttpTelemetry({service, sink, intervalMs?, routeLabel?, skipPrefixes?, …})` → `{middleware(), record, gauge, count, requestStarted, requestFinished, flush, stop}`, `createTelemetryMiddleware`, `telemetrySkipped`, `defaultRouteLabel`, `registerSignals`, `telemetrySample`, `validateTelemetrySample`, and the singleton `telemetry.{init, record, gauge, count, requestStarted, requestFinished, flush, stop}` + `telemetryMiddleware` (a request is aggregated per `route\|method\|status_class` per flush into one `platform.telemetry-sample@1` `http.request` sample, plus active-requests/p95/event-loop-lag gauges once per flush); re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` | | `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` | | `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects | Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident. ### How every call behaves (`core`) - **Where it goes.** Name a `service` and a `path`. The origin comes from `baseUrls[service]`, or from the registry: `discover()` reads `/.well-known/openvibe` and caches it for 5 minutes, and concurrent callers share one fetch. Use `baseUrls` for host-internal addresses such as `/internal/*` routes on `127.0.0.1`. - **Version negotiation.** The descriptor includes the contracts release the network runs. `discovery.compatible` says whether it falls within `CONTRACTS_RANGE` (`>=0.5.0 <1.0.0` for this release). By default an out-of-range version logs one warning. With `strictContracts: true` it throws `sdk.incompatible_contracts`. `client.supports(id)` checks whether a service is registered and running. - **Deadlines.** `timeoutMs` (default 10 s) applies to each attempt and covers reading the body. `deadlineMs` (default 30 s) bounds the whole call, retries included. An `AbortSignal` cancels the call. - **Retries.** A call is retried (default `retries: 2`, exponential backoff with jitter, `Retry-After` honoured) on network errors, timeouts, 408, 425, 429, 502, 503 and 504, and only when repeating it is safe: - GET, HEAD, OPTIONS, PUT and DELETE are always safe to repeat. - Any other method is retried only with an `Idempotency-Key`. When `retries > 0`, the client generates one for each mutation (`idem_`) and sends the same key on every attempt. - `idempotencyKey: false` turns this off for writes the server does not dedupe. The Community wrappers do that for create, fork, like, copy and comment. - `idempotent: true` marks a mutation the server dedupes by itself. Events publishing does, because Events dedupes on `event_id`. A 401 invalidates the token once and repeats the call with a fresh one. - **Errors.** Every failure is an `OpenVibeError` with `code`, `status`, `title`, `detail`, `requestId`, `traceId`, `errors` and `problem`: - RFC 9457 problems keep their `code`. - OAuth errors become their `error` (`invalid_client`). - Legacy `{ error: 'text' }` bodies become `http.`. - Failures with no HTTP response use `sdk.timeout`, `sdk.deadline_exceeded`, `sdk.aborted`, `sdk.network_error` or `sdk.unknown_service`. - **Tracing.** Every call sends `traceparent` and `X-OpenVibe-Request-Id`. The request id stays the same across retries. `client.withContext({ traceparent, requestId })` or `client.fromRequest(req)` continues the caller's trace with a new span. The `traceparent` option also accepts a getter, for example one backed by AsyncLocalStorage. - **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. Media and Community use offsets. Events `pull()` takes an opaque `after` cursor (a page's `next_cursor`, or `latest_cursor` to start at the head; a position is never a number, and `afterSeq` throws) and `iterate()` walks it, calling `onPage(page)` only after every item of that page was handled, so saving `page.next_cursor` there — it advances even when a page matched nothing — is crash-safe. - **Streams and downloads.** `responseType: 'response'` resolves with the raw fetch `Response` (`data` and `response`, body unread). Error statuses are still read and thrown. The timeout covers the headers only, and `signal` still cancels the body. `parseSSE(res.body)` (from `openvibe-sdk/realtime`) iterates a `text/event-stream`. ### Auth details - `createServiceTokenClient()` follows the rules of openvibe-contracts `serviceAuth.createTokenClient()` but needs no dependencies. It sends `POST /oauth/token` with `grant_type=client_credentials`, `audience` and an optional `scope`, which can be a string, an array, or a map from audience to scope. It keeps one cached token per audience until 60 s before expiry and shares one in-flight request. As a `tokenProvider`, each call gets a token for the audience of the service it calls: `openvibe.` by default, or the value in `audiences` if you set one. - `createNetworkKeys({ network, publicKey?, log? })` holds the keys a service verifies Network tokens with: a pinned PEM (`publicKey`) as is, or `/api/.well-known/jwks` through the shared JWKS client (a rotation is honoured on an unknown `kid`, the last good keys are kept through an outage). Call `start()` at boot (it retries every 30 s until the first load, then refreshes every 15 minutes) and `stop()` at shutdown; spread `keys.verifyOptions` into the verifiers below; `keys.loaded()` is your readiness check. ```js const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, log }); keys.start(); const claims = await verifyUserToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice' }); const svc = await verifyServiceToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice', contracts }); ``` - `verifyUserToken(token, { jwks, issuer, audience })` checks a token offline: - It accepts RS256 only. `alg: none`, HS256 and anything else fail as `token.malformed` before any key is used. - It checks the signature against the JWKS document, URL or client (`createNetworkKeys().verifyOptions`). A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string. - It checks `exp`, `nbf` and `iat` (30 s clock skew), then `iss` and `aud`. - It rejects principal tokens (a service, a developer app, a module or a node: a machine paired for a service) and typed tokens (a realtime ticket, a FedCM assertion: `typ` or `purpose` set) as `token.not_user`. It returns the claims, including `subject_id`. - `verifyServiceToken(token, { jwks, issuer, audience, contracts })` checks a service or app token for a service that receives them: the SDK chooses the key (the `kid`'s, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contracts `serviceAuth.verifyServiceToken` (pass the module as `contracts`). No key answers `{ ok: false, code: 'token.unavailable' }` with a fixed reason, never the internal JWKS URL. - `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts (v0.26 and later) `verifyServiceToken()` does: the same signature and time rules, the audience (required), `actor_type: app`, the claim shape (`sub app:app_…`, `cap`, `jti`, `project_id prj_…`, `env`, optional `on_behalf_of usr_…`), and `env: sandbox` refused as `token.sandbox_refused` unless `acceptSandbox: true`. Opt in only when your service keeps sandbox traffic apart from real data. It does not check capabilities: test `claims.cap` for the one your route performs. - `exchangeCode({ code, codeVerifier, redirectUri, clientId, clientSecret?, audience?, scope? })`: without `clientSecret` (a public app) the PKCE verifier is required; `app_…` clients must send the verifier and an `audience`. App tokens come back without a `refresh_token`. - `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps. - `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization. - PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app. - `createPgRevocationStore(db)` keeps each person's token cutoff from Network's `network.user.token_valid_after` event (Contracts 0.39.0: sign out everywhere, a password change or reset, a ban, staff ending someone's sessions). Subscribe to the event, pass each delivery to `store.apply(event)` (`'revoked'` means the cutoff moved: close that person's sockets, drop their cached sessions), and after `verifyUserToken()` refuse the token when `store.isRevoked(claims)`. That is Network's rule, `iat * 1000 < valid_after`. The cutoffs live in the service's PostgreSQL (`revocationSchema()` for its migration; `await store.load()` at boot reads them into memory); the store only ever moves a cutoff forward and ignores anything not from `network`. - `createSsoClient()` (`openvibe-sdk/sso`) is the site side of first-party sign-in, the layer Blog, Coupons, Deals, Host, News and Trade each carried a copy of. `/auth/login` sends the browser to the Network with a random `state` and a PKCE S256 challenge kept in short-lived httpOnly cookies on `/auth`; `/auth/callback` requires the state (constant-time compare) and exchanges the code server-side with the client secret and the verifier. `next` is a same-site path, this site's https origin or the Network's; anything else, including control characters and backslashes, goes home. Sessions are verified offline with `verifyUserToken` through the shared JWKS client (the last good keys survive a Network outage), so a service principal or a typed token (a realtime ticket, a FedCM assertion) is never a session. Cookies are host-only: `ov_token` (the access JWT, readable by the shared navbar), `ov_refresh` (httpOnly, `Path=/auth`) and `ov_sso_hint`. ### Receiving event webhooks OpenVibe.Events POSTs each delivery as `{ event }` (no sequence number: dedupe on `event.event_id`) with two signatures: - `X-OpenVibe-Signature: sha256=` (v1). It covers only the body, so a captured delivery verifies forever. - `X-OpenVibe-Timestamp: ` and `X-OpenVibe-Signature-V2: t=,v2=.">` (v2). Every attempt, retries included, is signed with the time it is sent, so a delivery stops verifying 300 s later. The key is the subscription secret. Verify the raw bytes, not re-serialized JSON: ```js const { parseDelivery } = require('openvibe-sdk/events'); app.post('/internal/events', express.raw({ type: 'application/json' }), (req, res) => { const d = parseDelivery(req.body, req.headers, process.env.EVENTS_WEBHOOK_SECRET, { requireV2: true }); if (!d) return res.sendStatus(401); inbox.once('my-service', d.event.event_id, () => { /* synchronous db writes */ }); // still dedupe: delivery is at least once res.sendStatus(204); }); ``` `parseDelivery(raw, headers, secret, { requireV2 = false, toleranceSec = 300, now })`: - If `X-OpenVibe-Signature-V2` is present, it must verify and its timestamp must be within ±`toleranceSec` of `now` (ms, default `Date.now()`). A bad or stale v2 returns `null`. It never falls back to v1. - If there is no v2 header, v1 is accepted only while `requireV2` is false (the default, so 0.4.0 changes nothing for existing callers). Turn `requireV2` on once Events sends v2 to you: a replayed v1-only delivery then fails. The lower-level checks are `verifyDeliveryV2(raw, headers, secret, { toleranceSec, now })` (constant-time; also refuses a `X-OpenVibe-Timestamp` that differs from `t`) and `verifyDelivery(raw, signatureHeader, secret)` (v1, unchanged). Keep your clock in sync (NTP): the window is ±300 s both ways. In tests, `signDeliveryHeaders(raw, secret, { now })` returns the three headers as Events sends them. ### The OpenVibe Frame Every OpenVibe site uses the same navbar and footer. Your app can use them too, so people move between it and the network without a jump: ```js // Browser (CommonJS or ESM: openvibe-sdk/frame) const { navbar } = await mountFrame({ service: 'myapp', brand: { name: 'My App' }, links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }], menu: { before: [{ label: 'My projects', href: '/projects' }] }, // rows in the account menu sessionUrl: '/auth/me', // your server session: { user } loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}', // Sign out ends your session too footer: { updates: '/updates' }, // or false for no footer }); ``` For server-rendered pages, `frameTags(opts)` returns `{ head, bodyStart, bodyEnd }`: put `head` in ``, `bodyStart` first in `` and `bodyEnd` last. Your CSP needs `script-src https://openvibe.network` and `connect-src https://openvibe.network`. The Frame is progressive: if openvibe.network cannot be reached, your page still renders, and the part that failed resolves to `null`. Signed-in state comes from the shared `ov_token`, and otherwise from your `sessionUrl`. To have your users recognised across the network, sign them in with OpenVibe (`openvibe-sdk/auth`). ### Tools `openvibe-sdk/tools` wraps the OpenVibe.Tools platform API (ADR-027, openvibe-contracts v0.33.0) on the gateway, `https://openvibe.tools`: every tool has a descriptor (`tools.tool@1`), and every tool with `api: true` runs through one route. **Status:** the registry routes come with Tools S3, the run API and the gateway's `/api/v1/jobs` facade with Tools S6 (the capabilities `tools.tool.read`, `tools.tool.run` and `tools.net.probe` stay `planned` until then). Until they are deployed, this client is tested against the contracts and the mock only. ```js const { createToolsClient, isToolRunError } = require('openvibe-sdk/tools'); const tools = createToolsClient(client); // client: createClient({ … }); a token is optional const { tools: list, count } = await tools.list({ family: 'img', api: true }); // tools.tool-list@1, schemas as $ref const png = await tools.get('png'); // tools.tool@1 with schemas embedded, or null const { $defs } = await tools.schema('png'); // { input, output }: what the $refs point at // An inline tool (execution client with a server engine, or sync) answers finished: const min = await tools.run('jsonminify', { text: '{ "a": 1 }' }); min.result.text; // '{"a":1}' // A job tool answers 202 with its job; wait() follows its events to the result: const run = await tools.run('png', {}, { files: [{ name: 'photo.jpg', data: bytes, type: 'image/jpeg' }] }); run.state; // 'queued' (run.job, run.location = /api/v1/jobs/job_…) const done = await run.wait({ onEvent: (e) => show(e.job.progress) }); done.result.files[0]; // { name: 'photo.png', mime, size, sha256, url, … } const bytesOut = await (await tools.jobs.file(done.job.id, 0)).arrayBuffer(); // One tool's output feeds the next, without a download: const webp = await (await tools.run('webp', {}, { files: [{ job_id: done.job.id, index: 0 }], waitMs: 10000 })).wait(); try { await tools.run('jsonminify', { text: '{' }); } catch (err) { if (isToolRunError(err)) console.log(err.code, err.status, err.detail); // the tool's own problem+json } ``` [Worker placement and execution receipts](docs/tools-routing.md): `tools.run(tool, input, { prefer })` and `tools.jobs.get(id)`. - **What `run()` resolves to.** An inline tool, or a job that finished within `waitMs`, gives `{ state: 'succeeded', tool, result, took_ms, job?, location? }`. `result` carries `data` (output kind json), `text` (kind text) or `files` (kind file or files: the job's result files). A job still queued or running gives `{ state: 'queued' | 'running', tool, job, location }`. Both have `idempotencyKey`, `replayed` and a non-enumerable `wait(opts)`. On a finished run `wait()` resolves to the run itself, so `await (await tools.run(…)).wait()` works for every tool. On a job, `wait()` takes `jobs.wait`'s options (`signal`, `onEvent`, `lastEventId`), and `tools.jobs.wait(job.id)` gives the raw job. - **Failures.** A run that finished `failed` or `cancelled` throws `ToolRunError`. It is an `OpenVibeError` with the tool's problem+json mapped: `code`, `status` (the problem's, e.g. 422 or 504, not the HTTP 200), `detail` and `errors`. It also carries `state`, `tool`, `job` and `run`. When `err.retryable`, `tools.jobs.retry(err.job.id)` runs the failed job again. A refusal before the tool ran is a plain `OpenVibeError`: 404 `tools.tool.not_found` or `tools.tool.not_runnable` (`api: false`: the YouTube downloader is page-only and never in the SDK), 422 `tools.input.invalid` (with `errors[]` pointers), 401 `token.*`, 403 `capability.denied`, 413/415 for files, 429 `quota.exceeded` or `tools.job.too_many_active`, 503 `tools.tool.unavailable`. - **Idempotency and retries.** Every run carries an `Idempotency-Key`, generated when you pass none and returned as `idempotencyKey`. The same key and request give the same job (`replayed: true`); a different request under that key is 409 `tools.job.idempotency_conflict`. Inline tools ignore the key. Because of the key, the client retries a 429 after its `Retry-After` (capped by `maxRetryDelayMs`) and retries 5xx and timeouts without creating a second job. To survive a crash between the run and saving the job id, derive the key from the request and run again with it. - **Files.** Uploads use the jobs client's shapes: a `Blob`/`File`, or `{ name, data: Buffer | Uint8Array | ArrayBuffer | Blob | string, type? }`. They go as multipart `file` parts. References are `{ media_id: 'med_…' }` (a Media object you may read) and `{ job_id: 'job_…', index }` (a result file of your own job). They go in the JSON body's `files`, or, beside uploads, as a multipart `files` part holding their JSON. The tool gets the uploads first, then the references in order, and the count must fit the descriptor's `files.min`/`files.max`. - **Waiting and timeouts.** `waitMs` (0 to 60000) lets a job tool answer finished when it is quick. Each attempt's timeout is raised to `waitMs`, or to the tool's `limits.timeoutMs` once this client has read its descriptor (`get` or `list`), plus 10 s. Pass `timeoutMs` to set it yourself. `signal` aborts the run (`sdk.aborted`), and a `wait()` too. Aborting a wait leaves the job running. - **Who may run what.** Callers are tiered anonymous < session < user < app/service. Without a token you run the tools whose descriptor says `auth.anonymous: true`, keyed by IP. A person's token runs every tool except network probes. An app or service token (audience `openvibe.tools`) needs the descriptor's `auth.capability`: `tools.tool.run` (public), or `tools.net.probe` (partner: staff grant it by hand). Quotas count each tool's `cost` within its `quotaClass`. Registry reads (`tools.tool.read`) are public and send no token unless you pass `anonymousReads: false`. - **Jobs.** `tools.jobs` is `createJobsClient()` on the same origin and credentials: the gateway's `/api/v1/jobs` facade, which fronts every satellite. It adds `retry(id)`, which returns `{ job, replayed }` (a failed job as a new one, `retry_of`; asking again returns the same retry), and `reference(id, 'community:paste:p_123')` / `unreference(id, ref)`, which keep a succeeded result while something points at it (`expires_at: null`). Until the facade is deployed (Tools S6), use `createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' })` (or `audio.`, `docs.`) for jobs. ### Testing your app ```js const { createMockPlatform } = require('openvibe-sdk/testing'); const platform = createMockPlatform({ clients: { 'my-service': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } }, apps: { 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR': { env: 'production', type: 'confidential', secret: 's', redirectUris: ['http://localhost:3009/callback'], grants: ['media.object.upload'] } }, mediaApps: { 'my-app': {} }, users: [{ username: 'ana' }], jobs: true, tools: true, }); const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR', clientSecret: 's', fetch: platform.fetch }) }); ``` For a service's own database, `createTestDb({ migrations, seed, seedKey })` returns a migrated PGlite (or, with `store: 'pg'`, a schema of its own on the test containers). The migrated and seeded PGlite is saved once as a snapshot, keyed by the migrations' contents, and later test processes load it instead of migrating (`snapshot: 'hit' | 'built' | 'off'`, `setupMs`; `OV_TEST_SNAPSHOT=0` turns it off, `OV_TEST_SNAPSHOT_DIR` moves it). The mock answers at the real public origins with real RS256 tokens and checks audience, capability, namespace and sandbox the way the services do. It covers: - **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`. - **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included). - **Sandbox:** as in production, Media (on `/api/v1//files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`. - **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries (v1 and v2 headers, fresh timestamp per attempt) to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app..`, source `app-`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server. - **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1//files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: , url_expires_at }` and `/f/` serves them only with a valid signature. - **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides). Each satellite keeps its own jobs, and `origins.tools` is the gateway facade that also finds theirs. Covered: submit with Idempotency-Key replay, get, cancel, retry, references, SSE with `Last-Event-ID` and `204` when finished, and result files. Every view is `tools.job@1`, with `error` as problem+json. `dropJobStreams()` simulates a dropped connection. - **Tools platform API** (`tools: true | { descriptors, handlers, mediaObjects, stepMs }`) on `origins.tools`: `GET /api/v1/tools[/:id[/schema]]` and `POST /api/v1/tools/:id/run`, answering as openvibe-contracts v0.33.0 says. The default tools are `dns` (sync), `jsonminify` (a client tool with a server engine), `png` (a job), `port` (a probe needing `tools.net.probe`), `yt` (page-only) and `protectpdf` (unavailable). `descriptors` adds or replaces tools, and `handlers` answers them (`{ data }` or `{ text }` inline, a job handler for job tools). Also: `addTool()`, `addMediaObject()` (what `{ media_id }` reads) and `state.tools`. The mock checks caller tiers, the refusal codes, the file count and type, and the input's top-level fields. Idempotent job runs, `wait_ms` and file references work. It has no browser sessions, so job tools need a token. It has no quotas either: to test a 429, wrap `platform.fetch`. Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent(envelope, publisher, { projectId, env })` (a registered app's `app:` publisher implies them), `pruneEvents()`, `deliverEvents()`, `dropRealtime()`, `dropJobStreams()`, `addTool()`, `addMediaObject()`, and `stats` and `state` for assertions. Its `fetch` rejects on an aborted signal, as `fetch` does. It is a fake. It has no persistence, its visibility rules are simplified, and it has no Chat (no WebSocket mock). ### Service kit (server) `openvibe-sdk/service` is the layer every service wrote around the shared pieces: a graceful stop and the error wrappers around the problem body. It does not reimplement readiness, metrics, `/release.json` or the problem body: those are re-exported from `openvibe-shared` (`/ready`, `/metrics`, `/release`) and `openvibe-contracts` (`http`), loaded from the service's own `node_modules` on first use (the SDK depends on neither; without the package the name throws an error that says which to install). ```js const svc = require('openvibe-sdk/service'); const ApiError = svc.createServiceError('ApiError'); // new ApiError(404, 'post.not_found', detail?, extra?) const o = { name: 'Blog API' }; // log prefix; extra: 'details' for Tips/VIP router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, o)); router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201, o)); // bad JSON: 400 request.invalid_json app.use(svc.jsonErrors(o)); // opt-in, last: 404 + error mapping const server = app.listen(port); const lifecycle = svc.gracefulStop({ name: 'Blog', server, stop: [() => worker.stop()], // nothing new starts close: [() => outbox.stop(), () => db.close()], // after the HTTP drain deadlineExitCode: 0, // the 5 s family exits 0 on a blown deadline }); ``` Errors follow Reviews/Wiki: a 5xx other than 503 is logged; the code is the error's, else `internal.error` at 500 and `request.invalid` otherwise; a 500's detail is `'Internal error'`, never the message. On a signal `gracefulStop` runs the stop steps, `beforeDrain`, then closes the server (`Connection: close` on requests in flight, idle keep-alive connections closed every 50 ms, event streams destroyed), the close steps and `handles`, and exits 0; past `deadlineMs` it exits `deadlineExitCode`. `stopping()` turns true at once, for a readiness check. The recipe per service family is [docs/service.md](docs/service.md). The kit also carries the HTTP telemetry Network wrote in `server/telemetry.js` and `server/observability.js`. A request is not a row: `createHttpTelemetry({ service, sink, skipPrefixes? })` aggregates per `route|method|status_class` per flush interval into `count`/`sum`/`max`/`p95` and emits one `http.request` `platform.telemetry-sample@1` per key, plus active-requests, p95 and event-loop-lag gauges once per flush. A request that matched no route is labelled `unmatched` (never its raw path), and the per-flush key map and latency samples are capped, so a scanner or a 404 flood cannot grow them without bound. Mount `collector.middleware()` before the routes and add `() => collector.stop()` to `gracefulStop`'s stop steps (one flush). The `telemetry.init({service, sink})` / `telemetryMiddleware` / `telemetry.stop()` singleton is the same design wired process-wide: health, readiness, metrics and static assets are skipped, `skipPrefixes` defaults to `['/shared']` (OpenVibe.Network passes `['/shared', '/api/chrome']`), the event-loop monitor starts in `init` and stops in `stop`, and requiring the kit starts nothing. ### Data: PostgreSQL and Valkey (server) Every service stores its data in PostgreSQL 18 through PgBouncer, and shares caches, limits, queues and fan-out through Valkey (ADR-035). The data role on the host provides both; `roles/data/add-service.sh` in OpenVibe.Host sets `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL` and `VALKEY_PREFIX`. ```js const { createDb, sql } = require('openvibe-sdk/db'); const { createValkey } = require('openvibe-sdk/valkey'); const { createCache } = require('openvibe-sdk/cache'); const { createQueue } = require('openvibe-sdk/queue'); const db = createDb({ service: 'wiki' }); // DATABASE_URL; tests: createDb({ pglite: true }) await createDb({ url: process.env.DATABASE_DIRECT_URL }).migrate({ dir: 'migrations' }); const page = await db.maybe(sql`SELECT id, title FROM pages WHERE slug = ${slug}`); await db.tx(async (t) => { … }, { isolation: 'serializable' }); const valkey = createValkey(); // null without VALKEY_URL: in-process fallbacks const cache = createCache({ valkey, namespace: 'pages' }); const html = await cache.getOrSet(`page:${slug}`, 300, () => render(slug)); const thumbs = createQueue({ valkey, name: 'thumbs' }); thumbs.process(async (job) => { … }, { concurrency: 4 }); ``` Install the drivers the service uses: `pg` and `iovalkey` (production), `@electric-sql/pglite` (tests and small embedded apps). PostgreSQL is the only database: the better-sqlite3 outbox, inbox and revocation store were removed in 0.44.0. ### Browser without a bundler ```html ``` `browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `bot`, `jobs`, `tools` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it. ### The OpenVibe camera element The browser bundle also offers `ov.cam.open()` for video, photo or screen capture in place. It loads Cam's SRI-pinned recorder once, then gives your page a `File` and metadata after the person reviews and chooses Use this: ```js import ov from 'openvibe-sdk'; const result = await ov.cam.open({ mode: 'video', maxSeconds: 60 }); if (result) await upload(result.file); ``` Closing Cam resolves to `null`. The calling site owns the file and upload. `openvibe-sdk/cam` also exports `open`, `loadCamKit`, `CAM_KIT_SRC` and `CAM_KIT_SRI` for direct use. `capture(options)` is the direct camera stream, on OpenVibe's one shared capture core: it loads the calling site's own `/shared/ov-capture.js` once (every OpenVibe site serves it) and calls `OVCapture.open(options)`, so `facing`, `lens`, `mic`, `resolution`, `audio` and `video` behave the same as Live's go-live page: ```js import { capture } from 'openvibe-sdk/cam'; const stream = await capture({ mode: 'camera', facing: 'environment', resolution: '1080' }); video.srcObject = stream; ``` `openvibe-sdk/cam` loads the camera app's framework-free `` element from OpenVibe.Cam and types its attributes, events and methods. It injects the embed script once and resolves to the element class: ```js import { defineOvCamera } from 'openvibe-sdk/cam'; await defineOvCamera(); // https://openvibe.cam/embed/ov-camera.js, once const camera = document.querySelector('ov-camera'); camera.addEventListener('ov-camera:capture', (event) => { // event.detail = { blob, width, height, filter }; nothing was uploaded upload(event.detail.blob); }); await camera.start(); // or let the person click the element's own button ``` ```html ``` `upload="off"` (the default) keeps the picture in the page. `upload="openvibe"` posts it to `POST /api/v1/photos` as the signed-in person, and only on openvibe.cam. `defineOvCamera({ src })` loads a self-hosted copy of the same file. The full attribute, event and method reference is at [openvibe.cam/developers](https://openvibe.cam/developers). ### Put your content in the OpenVibe menu Every open site can put its own cards in the network menu every site shares. Build the card with `menuCard` (it applies the house rules and validates against `frame.menu-card@1`), then mount the route — Express or plain `node:http`: ```js const { menuCard, menuRoute } = require('openvibe-sdk/menu'); const cards = async () => [menuCard({ id: 'new-worlds', site: 'games', title: 'New worlds', kind: 'rows', origin: 'https://openvibe.games', items: worlds.slice(0, 6).map((w) => ({ title: w.title, meta: `by ${w.ownerName}`, url: `https://openvibe.games/worlds/${w.id}` })), more: { label: 'All worlds', url: 'https://openvibe.games/worlds' }, })]; app.get('/.well-known/openvibe-menu.json', menuRoute(cards, { maxAgeSeconds: 300 })); ``` `items` may be empty (the card is valid and Network skips it until something is there). `origin` is the site's own https origin for the link rule; without it, the host of `more.url` is used, and with neither only `openvibe.*` links pass. A bad field throws a `TypeError` naming it. `menuRoute` calls `getCards()` on every request, validates each card, and caps the file at three cards; it answers `[]` and logs once rather than a 500 when the build fails or a card does not validate, so a transient error cannot take the site's file down. `OpenVibe.Network` fetches this file every five minutes and shows the cards in `GET /api/frame`. ## Versioning - `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag. - **The 1.0 core surface is already frozen.** `core`, `auth`, `events`, `db` and `media` are stable now, before 1.0: removing or renaming one of their exports needs a major release. `docs/one-point-oh.json` gives each name its tier (frozen, platform, leaving), `test/surface.test.js` pins the exact export list of the five entries, and `node scripts/surface.js --check` marks a break there as `[1.0 core]`. - [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …). `test/surface.test.js` fails when the exports and the file disagree, so a removal or a change of kind is a reviewed line in a diff, never a surprise on a service's next pin round; `node scripts/surface.js --check` names each change as breaking or added, and `node scripts/surface.js` updates the file. 1.0 freezes `core`, `auth`, `events`, `db` and `media` first: a breaking change there is marked `[1.0 core]`. Inside them each name is frozen, platform (exported, not frozen) or leaving before 1.0: [docs/one-point-oh.md](docs/one-point-oh.md). - Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release, the `openvibe-contracts` devDependency (temporarily the sharing branch), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency. - A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed. ## Not wrapped yet (intentionally) - **Community other-site comments and forum API** remain outside this SDK surface; shared typed comment thread routes are wrapped by `community.comments`. - **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects. - **Media from the browser:** Media refuses user JWTs on the v1 files and v2 objects APIs, so a browser uploads through its own app server. That server holds the app credential and names the user with `actingUserId` or a subject. - **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass. - **Billing and Games** from the original charter come when those services publish their capabilities in Contracts (Live has its module since v0.52.0). Codes' tasks (they run code on a person's own machine) stay first-party, and OpenVibe.Watch is not built yet; Host and News have modules for their public reads, while editing stays first-party. Blog opened its posts to apps (openvibe-contracts 0.206.0, `openvibe-sdk/blog`), then Reviews and the Wiki their reads (0.207.0), then Coupons, Deals and Trade what a person does for themselves (0.208.0), then Space its sites (0.209.0). News' published stories need no capability, but its editorial reads stay first-party. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat. - **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK. - **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport. - **Bot:** the REST robots, public kits/profiles and pairing routes are wrapped (`openvibe-sdk/bot`). The operator WebSocket (`/control`, Realtime frames `bot.command@1`), device bootstrap (`POST /devices/bind`, a Network node token) and the internal Run→Bot job dispatch (`bot.job.dispatch`, service-to-service and never delegated to people or apps) are not. ## Capabilities The SDK implements no capability and holds no grant: every client calls with the token the app gives it, so the app needs the grant for what it calls (for example `media.object.upload`, `events.app.publish`, `search.query.delegate`, `ai.run.create`). What each subpath wraps, and the capability each call performs, is in the [API](#api) table; a capability with no wrapper is not public. ## Acceptance `npm test` runs every `test/*.test.js` against local stub servers and the mock platform, then packs the package and installs it. What it proves: each client's requests and error handling (one test file per subpath), token verification, PKCE and revocation cutoffs (`jwt`, `pkce`, `auth-tokens`, `revocations`), webhook signatures and the outbox/inbox (`events`, `outbox`), the per-actor limiter (`limits`), that an external app authenticates, discovers Media from the registry and uploads through the mock platform (`testing`), that the ESM entries, types and browser bundle match the sources (`esm`, `types`, `bundle`, `browser-entries`), and that the packed tarball contains every entry point (`pack`). ## Security Reporting a vulnerability: [SECURITY.md](SECURITY.md). Browser-safe files never touch a client secret (the browser secret scan covers the bundle); secrets and tokens are never logged; a write is retried only with an idempotency key; webhook verification checks signature v2 within ±300 s (`requireV2` refuses a v1-only delivery); the Frame loads only from an https origin (or `http://localhost` in development). The package has no runtime dependencies. ## Deploy Nothing is deployed from this repository: it is a package. A release is a git tag (`vX.Y.Z`, with its [CHANGELOG.md](CHANGELOG.md) entry); services and apps pin the tag's tarball and ship it with their own deploys (`sudo ovhost deploy ` for OpenVibe services). A bad release is undone by pinning the previous tag. ## Development ```bash npm test # every test/*.test.js: stub servers + mock platform, then npm pack + install fnm exec --using=22.22.1 npm test # the Node version production runs node scripts/esm.js # regenerate esm/*.mjs after changing a module's exports node scripts/browser-bundle.js # regenerate browser/openvibe-sdk.mjs after changing a browser-safe file ``` Style: CommonJS, 4-space indent, single quotes, semicolons. Browser-safe files use only `fetch`, `Headers`, `FormData`, `Blob`, `URL`, `TextEncoder`/`TextDecoder` and `globalThis.crypto` (Web Crypto). Server-only files may use `node:` modules and are marked `"browser": null` in `package.json`. ## Owns Owns the supported client surface, version negotiation and feature detection, and the mock adapters for development. It replaces the hand-written per-repo clients: Live's `media-client`, Community's proxies and Tools' fetch helpers. ## Does not own Service implementations and their data (each service), the contracts (OpenVibe.Contracts, whose types it copies), and UI components (OpenVibe.Shared; the Frame module loads Shared's published files, it does not bundle them). ## Depends on Nothing at runtime. `openvibe-contracts` and `@electric-sql/pglite` are devDependencies (type checks, the database, outbox and inbox tests). At run time the clients talk to OpenVibe.Network (tokens, registry, discovery) and the service each one wraps. --- Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). - openvibe-contracts: v0.419.0 ### Network-wide sharing (Node) `openvibe-sdk/share` provides local access enforcement, expiring memberships and confirmed ownership transfer. See [the sharing module](docs/share.md) for integration, API signatures, migrations and the uniform OpenAPI fragment. Agents use `require('openvibe-sdk').share({ base, token }).for('paste', id)`. ### Locker: end-to-end encryption (Node and the browser) `openvibe-sdk/locker` seals anything and opens it again: the Locker envelope (`locker.envelope@1`), the three locks (people, password, link), streams for files, person key pairs and the eight-word fingerprint, `wrapFor` and `rewrap` for sharing, `recoveryWords` for the phrase to write down, `inspect` for what an envelope says about itself, and opening a people lock by key. `openvibe-sdk/locker/server` gives a service the header checks without a key. WebCrypto only, in Node and the browser. See [the locker module](docs/locker.md). # AI comparison and Arena Read Report's public AI data and create or vote on Exchange Arena rounds: ```js const ov = require('openvibe-sdk'); const client = ov.createClient(); const report = ov.report.createReportClient(client); const leaders = await report.ai.leaderboard({ kind: 'code', limit: 25 }); const headToHead = await report.ai.compare(['openai/gpt-4o', 'anthropic/claude-sonnet']); const mine = await report.me({ days: 30 }); // private by default; the switches say what is public await report.publish({ sites: true, activity: true, made: false, ai: false, money: false }); const theirs = await report.person('goosely'); // only the sections they published const exchange = require('openvibe-sdk/exchange').createExchangeClient({ developerKey }); const round = await exchange.arena.start({ prompt: 'Explain a binary search.', category: 'code', entries }); await exchange.arena.vote(round.id, 'left'); ``` ## Meme encyclopedia and renderer Search cited meme entries, read source and remix timelines, list reusable templates, and render a caption to raw image/video bytes: ```js const ov = require('openvibe-sdk'); const client = ov.createClient(); const memes = ov.meme.createMemeClient(client); const { entries } = await memes.search({ scope: 'internet', q: 'Doge' }); const { templates } = await memes.templates(); const png = await memes.render({ template_id: templates[0].id, lines: ['Much quality', 'such tiny SDK'] }); // png.bytes is an ArrayBuffer; png.contentType is image/png (or image/gif / video/mp4). ``` See [the Meme client](docs/meme.md) for the render options and response details. ## Camera settings Camera choices can be read or saved on behalf of a signed-in person with `ov.cam.settings.get({ token })` and `ov.cam.settings.put({ token, values: { facing, lens, mic, resolution, mirror } })`. Server callers should forward the person's Network token; Cam keeps these preferences per person, and a missing device id is only a hint. ### Pictures ```js const { createPicsClient } = require('openvibe-sdk/pics'); const pics = createPicsClient(client); const gallery = await pics.gallery({ sort: 'new', tag: 'landscape' }); const session = await pics.uploads.create({ size: bytes.length, content_type: 'image/png', name: 'photo.png' }); // Send at most 1 MiB per chunk; read uploads.get(id).offset after an interrupted request. await pics.uploads.chunk(session.id, 0, bytes); const picture = await pics.uploads.complete(session.id); // private await pics.publish(picture.slug, { listed: true, tags: ['landscape'] }); ``` `pics.albums` lists, creates and manages albums; `pics.like(slug, false)` removes a like. Upload and write calls require a person's Network token. Gallery reads are public. Resumable completion and likes are idempotent; creation is not retried.