Mirror of github.com/OpenVibers/OpenVibe.SDK
  • JavaScript 99.8%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
OpenVibers 23c3772729
openvibe-contracts v0.215.0 (was v0.214.1) (#86)
Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.215.0 release.
2026-10-27 03:59:25 -07:00
.github/workflows SDK 0.14.0: the async data layer and the Valkey modules (ADR-035 and its amendment; roadmap WS-X2). openvibe-sdk/db: createDb({ url | pglite, service }) with query, many, maybe, one, value and exec on a safe sql tagged template (every value a bind parameter, nesting fragments, sql.ident/set/insert/join/json); tx(fn, { isolation, retries }) retrying 40001 and 40P01, with nested savepoints; ready() reporting the store that answered; query-time histogram, pool gauges and a slow-query log without values. node-postgres through PgBouncer in production and PGlite (real PostgreSQL in-process) in tests return identical rows: int8 as Number (refused past 2^53), timestamps as ISO strings, dates as YYYY-MM-DD, numeric as exact text, json parsed. db.migrate runs numbered expand/migrate/contract SQL files once, with checksums (edits refused), order kept, an advisory lock, contract migrations held until their expand is 7 days old, and -- no-transaction. importSqlite moves a service's SQLite file into its schema: parents first, conversion by target type, identities kept and sequences advanced, lost columns refused unless dropped, streamed verification of counts and checksums. openvibe-sdk/valkey (auto-pipelined, prefix-confined), cache (cross-process single-flight, tags), queue (at-least-once on streams: retries with backoff, dead letters, delays, dedupe, reclaim of hung workers' jobs), pubsub, and createValkeyLimitStore for limits shared by every process (in-process counting if Valkey fails); each has an in-process twin. Drivers are optional peers loaded on use. scripts/test-services.sh starts PostgreSQL 18 + PgBouncer (transaction, SCRAM) + Valkey 9 containers; CI runs the suite against them. npm test: 35/35 with the containers (the same db cases pass on PGlite and through PgBouncer; 8 concurrent serializable increments all land), 33/35 with 2 skipped without them. 2026-09-28 04:00:03 -07:00
bin SDK 0.49.0: openvibe-sdk/download and the openvibe-download command (folder backups) (#53) 2026-10-15 17:09:54 -07:00
browser 0.65.0 (10-26 batch) (#83) 2026-10-25 17:37:32 -07:00
docs openvibe-sdk/chat: Vibe, support inboxes, feeds; createInboxVisitor and createFeedPublisher (#78) 2026-10-25 04:41:18 -07:00
esm openvibe-sdk/chat: Vibe, support inboxes, feeds; createInboxVisitor and createFeedPublisher (#78) 2026-10-25 04:41:18 -07:00
scripts openvibe-sdk/themes: the account's theme, display preferences and seasons (createThemesClient), and followTheme for pages, canvases and games (#80) 2026-10-24 19:47:40 -07:00
src 0.65.0 (10-26 batch) (#83) 2026-10-25 17:37:32 -07:00
test 0.65.0 (10-26 batch) (#83) 2026-10-25 17:37:32 -07:00
types openvibe-contracts v0.214.0 (was v0.213.0) (#84) 2026-10-25 19:07:14 -07:00
.gitignore Load test databases from a migrated PGlite snapshot in createTestDb (#12) 2026-10-04 18:34:43 -07:00
browser.js openvibe-sdk/themes: the account's theme, display preferences and seasons (createThemesClient), and followTheme for pages, canvases and games (#80) 2026-10-24 19:47:40 -07:00
CHANGELOG.md 0.65.0 (10-26 batch) (#83) 2026-10-25 17:37:32 -07:00
index.js openvibe-sdk/themes: the account's theme, display preferences and seasons (createThemesClient), and followTheme for pages, canvases and games (#80) 2026-10-24 19:47:40 -07:00
LICENSE openvibe-sdk 0.1.0: core client, auth, registry, identity, modules, events, realtime, media, community and a mock platform 2026-09-22 18:20:42 -07:00
package-lock.json openvibe-contracts v0.215.0 (was v0.214.1) (#86) 2026-10-27 03:59:25 -07:00
package.json openvibe-contracts v0.215.0 (was v0.214.1) (#86) 2026-10-27 03:59:25 -07:00
README.md openvibe-contracts v0.215.0 (was v0.214.1) (#86) 2026-10-27 03:59:25 -07:00
SECURITY.md SECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions) 2026-09-23 19:27:47 -07:00
STATUS.json openvibe-contracts v0.215.0 (was v0.214.1) (#86) 2026-10-27 03:59:25 -07:00

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 service and a path. The origin comes from baseUrls[service], or from the registry: discover() reads <network>/.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_<ULID>) 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.<status>.
    • 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.<service> 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 <network>/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.

    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=<hex HMAC-SHA256 of the raw body> (v1). It covers only the body, so a captured delivery verifies forever.
  • X-OpenVibe-Timestamp: <unix seconds> and X-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-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:

// 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 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

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/<project_id>/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.<project_key>.<name…>, source app-<ulid>, 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/<its project_id>/files, where production uses the tenant prj_… and sandbox prj_…-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 }) 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:<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-sdk follows 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.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.
  • 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 (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.js until 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 with actingUserId.
  • 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 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