- JavaScript 99.8%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| bin | ||
| browser | ||
| docs | ||
| esm | ||
| scripts | ||
| src | ||
| test | ||
| types | ||
| .gitignore | ||
| browser.js | ||
| CHANGELOG.md | ||
| index.js | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.SDK
Supported browser and server clients for the OpenVibe platform.
Status: alpha, v0.65.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see 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). 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.
npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.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). It does not depend on openvibe-contracts at runtime: it copies the contract types it uses (from Contracts v0.86.0, 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)
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)
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). An app is a principal app_<ULID>: 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:
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/<key>
The token's env picks the tenant: a sandbox app's files go to the project's sandbox tenant (app_id: '<prj_…>-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>.* (project_key = p + the lowercased project ULID) with source: app-<lowercased app ULID>. createAppEvents() fills that in, and scopes pulls and subscriptions to the project:
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_…).
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:
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):
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(), pkceChallenge(), 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}) |
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.<project_key>.*), 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/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, as(subject) |
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), 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/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) |
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 |
openvibe-sdk/chat |
both | createChatClient(client): global.send/history/search, rooms.* (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), dms.* (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), 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, …}), runs.get/list/cancel/retry/citations/addCitations/waitFor, chat/generate/summarize/classify/extract/enrich/embed |
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/tools |
both | createToolsClient(client, {baseUrl?, anonymousReads?}): list({family, q, execution, api, status}), get(id), schema(id), run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs}) (+ .wait()), jobs; ToolRunError, isToolRunError() |
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/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 <folder> --to <drive/path>, ls, usage (OPENVIBE_DOWNLOAD_KEY, a sync key from openvibe.download/sync) |
openvibe-sdk/quest |
server | createQuestClient(client): appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?}), appQuests.list(), appQuests.retire(slug) with an app token (audience openvibe.quest, quest.app_quest.manage); eventType(projectId, name) gives the app.<project_key>.<name> type to publish at Events. Badges only, never OpenCoins; 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/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.equipped(subject), equipped([subjects]) (≤ 100, for chat renders). The person's own: me.items(), `me.equip({kind, slot, instance_id |
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), 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/no-looks |
any | Public AI text never describes a person: noLooks(text) removes looks, body, clothing, hair, face, pose, age and guessed gender ("a shirtless man sits" → "the streamer sits", "two men" → "two people"; quoted speech and names stay), noLooksTags(tags) drops tags that name them, hasLooks(title) says a title should be replaced, and RULES_VERSION gates a one-time repair of stored rows. Every service that stores or serves public AI text runs it |
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/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/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/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 |
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/node |
server | nodeCapabilities() and validateNodeCapabilities() for platform.node-capabilities@1; nodeOffers() 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
serviceand apath. The origin comes frombaseUrls[service], or from the registry:discover()reads<network>/.well-known/openvibeand caches it for 5 minutes, and concurrent callers share one fetch. UsebaseUrlsfor host-internal addresses such as/internal/*routes on127.0.0.1. -
Version negotiation. The descriptor includes the contracts release the network runs.
discovery.compatiblesays whether it falls withinCONTRACTS_RANGE(>=0.5.0 <1.0.0for this release). By default an out-of-range version logs one warning. WithstrictContracts: trueit throwssdk.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. AnAbortSignalcancels the call. -
Retries. A call is retried (default
retries: 2, exponential backoff with jitter,Retry-Afterhonoured) 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. Whenretries > 0, the client generates one for each mutation (idem_<ULID>) and sends the same key on every attempt. idempotencyKey: falseturns this off for writes the server does not dedupe. The Community wrappers do that for create, fork, like, copy and comment.idempotent: truemarks a mutation the server dedupes by itself. Events publishing does, because Events dedupes onevent_id.
A 401 invalidates the token once and repeats the call with a fresh one.
-
Errors. Every failure is an
OpenVibeErrorwithcode,status,title,detail,requestId,traceId,errorsandproblem:- RFC 9457 problems keep their
code. - OAuth errors become their
error(invalid_client). - Legacy
{ error: 'text' }bodies becomehttp.<status>. - Failures with no HTTP response use
sdk.timeout,sdk.deadline_exceeded,sdk.aborted,sdk.network_errororsdk.unknown_service.
- RFC 9457 problems keep their
-
Tracing. Every call sends
traceparentandX-OpenVibe-Request-Id. The request id stays the same across retries.client.withContext({ traceparent, requestId })orclient.fromRequest(req)continues the caller's trace with a new span. Thetraceparentoption 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. Eventspull()takes an opaqueaftercursor (a page'snext_cursor, orlatest_cursorto start at the head; a position is never a number, andafterSeqthrows) anditerate()walks it, callingonPage(page)only after every item of that page was handled, so savingpage.next_cursorthere — it advances even when a page matched nothing — is crash-safe. -
Streams and downloads.
responseType: 'response'resolves with the raw fetchResponse(dataandresponse, body unread). Error statuses are still read and thrown. The timeout covers the headers only, andsignalstill cancels the body.parseSSE(res.body)(fromopenvibe-sdk/realtime) iterates atext/event-stream.
Auth details
-
createServiceTokenClient()follows the rules of openvibe-contractsserviceAuth.createTokenClient()but needs no dependencies. It sendsPOST /oauth/tokenwithgrant_type=client_credentials,audienceand an optionalscope, 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 atokenProvider, each call gets a token for the audience of the service it calls:openvibe.<service>by default, or the value inaudiencesif you set one. -
createNetworkKeys({ network, publicKey?, log? })holds the keys a service verifies Network tokens with: a pinned PEM (publicKey) as is, or<network>/api/.well-known/jwksthrough the shared JWKS client (a rotation is honoured on an unknownkid, the last good keys are kept through an outage). Callstart()at boot (it retries every 30 s until the first load, then refreshes every 15 minutes) andstop()at shutdown; spreadkeys.verifyOptionsinto the verifiers below;keys.loaded()is your readiness check.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 astoken.malformedbefore 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 unknownkid. It also accepts the Network's legacypublic_keyfield or a PEM string. - It checks
exp,nbfandiat(30 s clock skew), thenissandaud. - 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:
typorpurposeset) astoken.not_user.
It returns the claims, including
subject_id. - It accepts RS256 only.
-
verifyServiceToken(token, { jwks, issuer, audience, contracts })checks a service or app token for a service that receives them: the SDK chooses the key (thekid's, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contractsserviceAuth.verifyServiceToken(pass the module ascontracts). 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, optionalon_behalf_of usr_…), andenv: sandboxrefused astoken.sandbox_refusedunlessacceptSandbox: true. Opt in only when your service keeps sandbox traffic apart from real data. It does not check capabilities: testclaims.capfor the one your route performs. -
exchangeCode({ code, codeVerifier, redirectUri, clientId, clientSecret?, audience?, scope? }): withoutclientSecret(a public app) the PKCE verifier is required;app_…clients must send the verifier and anaudience. App tokens come back without arefresh_token. -
startAuthorization()/buildAuthorizeUrl()defaultscopeto'profile theme'only for first-party sign-in (noaudience). Apps passaudienceand capability ids. Network refusesprompt=nonefor 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'snetwork.user.token_valid_afterevent (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 tostore.apply(event)('revoked'means the cutoff moved: close that person's sockets, drop their cached sessions), and afterverifyUserToken()refuse the token whenstore.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 fromnetwork. -
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/loginsends the browser to the Network with a randomstateand a PKCE S256 challenge kept in short-lived httpOnly cookies on/auth;/auth/callbackrequires the state (constant-time compare) and exchanges the code server-side with the client secret and the verifier.nextis 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 withverifyUserTokenthrough 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) andov_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=<hex HMAC-SHA256 of the raw body>(v1). It covers only the body, so a captured delivery verifies forever.X-OpenVibe-Timestamp: <unix seconds>andX-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">(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:
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-V2is present, it must verify and its timestamp must be within ±toleranceSecofnow(ms, defaultDate.now()). A bad or stale v2 returnsnull. It never falls back to v1. - If there is no v2 header, v1 is accepted only while
requireV2is 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:
// 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 <head>, bodyStart first in <body> 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.
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
}
- What
run()resolves to. An inline tool, or a job that finished withinwaitMs, gives{ state: 'succeeded', tool, result, took_ms, job?, location? }.resultcarriesdata(output kind json),text(kind text) orfiles(kind file or files: the job's result files). A job still queued or running gives{ state: 'queued' | 'running', tool, job, location }. Both haveidempotencyKey,replayedand a non-enumerablewait(opts). On a finished runwait()resolves to the run itself, soawait (await tools.run(…)).wait()works for every tool. On a job,wait()takesjobs.wait's options (signal,onEvent,lastEventId), andtools.jobs.wait(job.id)gives the raw job. - Failures. A run that finished
failedorcancelledthrowsToolRunError. It is anOpenVibeErrorwith the tool's problem+json mapped:code,status(the problem's, e.g. 422 or 504, not the HTTP 200),detailanderrors. It also carriesstate,tool,jobandrun. Whenerr.retryable,tools.jobs.retry(err.job.id)runs the failed job again. A refusal before the tool ran is a plainOpenVibeError: 404tools.tool.not_foundortools.tool.not_runnable(api: false: the YouTube downloader is page-only and never in the SDK), 422tools.input.invalid(witherrors[]pointers), 401token.*, 403capability.denied, 413/415 for files, 429quota.exceededortools.job.too_many_active, 503tools.tool.unavailable. - Idempotency and retries. Every run carries an
Idempotency-Key, generated when you pass none and returned asidempotencyKey. The same key and request give the same job (replayed: true); a different request under that key is 409tools.job.idempotency_conflict. Inline tools ignore the key. Because of the key, the client retries a 429 after itsRetry-After(capped bymaxRetryDelayMs) 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 multipartfileparts. 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'sfiles, or, beside uploads, as a multipartfilespart holding their JSON. The tool gets the uploads first, then the references in order, and the count must fit the descriptor'sfiles.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 towaitMs, or to the tool'slimits.timeoutMsonce this client has read its descriptor (getorlist), plus 10 s. PasstimeoutMsto set it yourself.signalaborts the run (sdk.aborted), and await()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 (audienceopenvibe.tools) needs the descriptor'sauth.capability:tools.tool.run(public), ortools.net.probe(partner: staff grant it by hand). Quotas count each tool'scostwithin itsquotaClass. Registry reads (tools.tool.read) are public and send no token unless you passanonymousReads: false. - Jobs.
tools.jobsiscreateJobsClient()on the same origin and credentials: the gateway's/api/v1/jobsfacade, which fronts every satellite. It addsretry(id), which returns{ job, replayed }(a failed job as a new one,retry_of; asking again returns the same retry), andreference(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), usecreateJobsClient(client, { baseUrl: 'https://img.openvibe.tools' })(oraudio.,docs.) for jobs.
Testing your app
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 assetAuthorization({ subjectId }), or declines with{ decision: 'deny' }), the JWKS, the registry,/api/v1/projects(projects, members, apps, credentials, grants, quotas, audit),/api/modulesand/internal/modules, and/internal/identity. - Developer apps:
apps/projectsoptions,addApp(),signAppToken(). App tokens carrysub app:…,cap,ns: [project_id],project_id,envandon_behalf_of(code flow); codes are single use, PKCE-bound and bound to theaudiencegiven 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/<project_id>/files) and Events (on theevents.app.*routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refusesenv: sandboxtokens (401 token.sandbox_refused) unlessacceptSandboxlists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you passsandboxAudiences, and projects created through the API start with the whole catalog as allowance unless you passdefaultAllowance: []. - Events: publish with
event_iddedupe, pull (with agapafterpruneEvents(seq)), checkpoints, subscriptions,/realtime/streamSSE withLast-Event-IDand 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 aftermax_attempts. Developer apps follow OpenVibe.Events' rules: app tokens are judged only onevents.app.publish | read | subscribe; typesapp.<project_key>.<name…>, sourceapp-<ulid>, actor the app or itson_behalf_ofuser; 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 throughapp.*; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker'sfetch(deliverEvents({ fetch })) can routehttps://hooks.example.com/…to a local server. - Media: the files API and
GET /f/:key, with Media's tenant rules:media.object.uploaduploads and deletes,media.object.readlists and gets (app keys, service tokens and app tokens alike); a developer app reaches only/api/v1/<its project_id>/files, where production uses the tenantprj_…and sandboxprj_…-sandbox(100 MB,mediaQuotaMb); sandbox files come back as{ sandbox: true, url: <signed>, url_expires_at }and/f/<key>serves them only with a valid signature. - Tools jobs (
jobs: true | { stepMs, handlers }) atorigins.toolsand the satellitesimg.,audio.anddocs.openvibe.tools(platform.toolsOrigins;toolsSatellitesoverrides). Each satellite keeps its own jobs, andorigins.toolsis the gateway facade that also finds theirs. Covered: submit with Idempotency-Key replay, get, cancel, retry, references, SSE withLast-Event-IDand204when finished, and result files. Every view istools.job@1, witherroras problem+json.dropJobStreams()simulates a dropped connection. - Tools platform API (
tools: true | { descriptors, handlers, mediaObjects, stepMs }) onorigins.tools:GET /api/v1/tools[/:id[/schema]]andPOST /api/v1/tools/:id/run, answering as openvibe-contracts v0.33.0 says. The default tools aredns(sync),jsonminify(a client tool with a server engine),png(a job),port(a probe needingtools.net.probe),yt(page-only) andprotectpdf(unavailable).descriptorsadds or replaces tools, andhandlersanswers them ({ data }or{ text }inline, a job handler for job tools). Also:addTool(),addMediaObject()(what{ media_id }reads) andstate.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_msand file references work. It has no browser sessions, so job tools need a token. It has no quotas either: to test a 429, wrapplatform.fetch.
Helpers: signUserToken(), signServiceToken(), signAppToken(), authorize(), setAuthorization(), publishEvent(envelope, publisher, { projectId, env }) (a registered app's app:<id> 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).
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.
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.
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
<script type="module">
import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs'; // copied or served from node_modules/openvibe-sdk/browser/
const { url, state, codeVerifier } = await auth.startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] });
</script>
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.
Versioning
openvibe-sdkfollows semver. While it is 0.x, a minor release may break an API, and CHANGELOG.md says so. Pin a tag.- docs/api-surface.json lists every name each entry exports and what it is (function, class, a plain object's keys, …).
test/surface.test.jsfails 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 --checknames each change as breaking or added, andnode scripts/surface.jsupdates the file. 1.0 freezescore,auth,events,dbandmediafirst: 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. - Each release states the openvibe-contracts range it was tested against (
CONTRACTS_RANGE), anddiscover()checks it at runtime. Contract types are copied intotypes/contracts.d.ts.test/types.test.jsfails if they differ from the pinned Contracts release, theopenvibe-contractsdevDependency (a release tarball pin, v0.86.0), 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 comments on other content, the forum and Pulse are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in
src/community.jsuntil those APIs settle. - 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
/api/v1/:app/files, so a browser uploads through its own app server. That server holds the app key and names the user withactingUserId. - 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.sendisfirst-party) and the mock platform has no Chat. - Tools are not wrapped one by one:
openvibe-sdk/toolsruns any tool withapi: trueby id, and its descriptor (tools.get(id)) gives the input schema, files and limits.openvibe-sdk/jobsstill submits any jobtypedirectly. 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 framesbot.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 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. 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 entry); services and apps pin the tag's tarball and ship it with their own
deploys (sudo ovhost deploy <service> for OpenVibe services). A bad release is undone by pinning the
previous tag.
Development
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. Built in the open by OpenVibers.
- openvibe-contracts: v0.215.0