openvibers/openvibe.sdk README.md
README.md
OpenVibe.SDK
Open source JavaScript/TypeScript SDK and API clients for the OpenVibe network — docs at openvibe.services.
Docs: https://openvibe.services · For AI: https://openvibe.services/llms.txt
Install and test: npm ci && npm test (Node 20 or newer)
npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0
Supported browser and server clients for the OpenVibe platform.
Status: alpha, v0.164.0 (the five 1.0 core entries — core, auth, events, db and media — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's STATUS.json names the one it uses.
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
License: MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
If a capability is not in the SDK, it is not public. Apps call services through openvibe-sdk and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
After v0.97.1 is tagged:
npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0
It has no runtime dependencies. It needs Node ≥ 20, or any browser with fetch, Web Crypto and TextDecoder. There is no build step. The package is CommonJS with ESM entry points (import works). Each subpath has its own .d.ts. For a page with no bundler, browser/openvibe-sdk.mjs is one self-contained ES module of the browser-safe subpaths (see Browser without a bundler). It does not depend on openvibe-contracts at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
Purpose
The one supported way to call OpenVibe from an app, first-party or not: clients for each public
capability, token handling, discovery and version checks, webhook verification, the outbox and inbox
services use, the per-actor limiter, the OpenVibe Frame, and a mock platform to test against.
Quick start
Browser app (signed-in user)
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>
For large files, use Media's resumable multipart object API. The uploader uses 8 MiB parts,
three concurrent requests, retries missing parts, and exposes the upload reference before
any bytes leave so the app can persist it. Keep the credential on a server.
const uploader = sdk.mediaUpload.createUploadClient({ app: unverifiedClaims.project_id, tokenClient: tokens });
const object = await uploader.upload(file, {
onSession: ({ objectId, uploadId }) => saveUploadReference({ objectId, uploadId }),
onProgress: ({ uploadedBytes, totalBytes }) => reportProgress(uploadedBytes, totalBytes),
});
// After a restart: uploader.resume(savedReference, sameFile, { onProgress });
On a page that loads the pinned ov-upload.js and ov-upload.css from
openvibe-shared, sdk.ui.progress(el) mounts the same progress component used
across OpenVibe sites.
The token's env picks the tenant: a sandbox app's files go to the project's sandbox tenant (app_id: '<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(), readCallback(), decodeUnverified(), unverifiedClaims() |
| openvibe-sdk/registry | both | createRegistryClient(client): services({status}), service(id), capabilities({owner}), capability(id), namespaces(), contracts(), topics(), domain(host), descriptor() |
| openvibe-sdk/identity | server | createIdentityClient(client): resolve({subjectId} \| {system,type,id}), resolveBatch({subjectIds} \| {system,type,ids}); createAccountEmailsClient({getToken}) manages the signed-in person's verified addresses ([guide](docs/identity-emails.md)) |
| openvibe-sdk/trust | both | createTrustClient({networkUrl, getToken}): me() reads the caller's own account or guest signals; of(subject) reads an account or guest with identity.trust.read; isNew(signals, {hours}), atLeast(signals, band), AGE_BANDS |
| openvibe-sdk/user-card | both | createUserCardClient(client): get(username) reads the public Network aggregate (display name, avatar, level, streak, badges, followers, active sites). No credentials; hidden or unavailable fields stay absent ([guide](docs/user-card.md)) |
| openvibe-sdk/notifications | server | createNotificationsClient(client): push({subjectId \| userId, type, title, message, url, category, priority, …}) through Network (network.notifications.push); { sent, skipped } or { sent: false, reason: 'unknown_subject' }; attempted once, the caller retries |
| openvibe-sdk/modules | both | createModulesClient(client): get, put(ns, data, {revision}), delete, list, update(ns, fn), publicGet; forSubject.get/put/update for services |
| openvibe-sdk/events | server | createEventsClient(client, {source}): publish, prepare, pull, iterate, get, get/setCheckpoint, subscriptions.create/list/get/disable/enable (subscribe), deliveries, replay; parseDelivery (v1 and v2, requireV2), verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders, verifyDelivery, signDelivery; developer apps: createAppEvents(client, {projectId, appId, onBehalfOf?}) (same calls, scoped to app.<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/composer | both | createComposerClient(client, {mediaBaseUrl?, networkBaseUrl?}): attachments.upload(file, {onProgress, signal}) (multipart to Media through XMLHttpRequest, real browser progress), attachments.remove(id), composer.prefs.get()/put(prefs, rev) (a stale revision is 409 composer.rev_conflict), mentions.search(q, kinds) |
| openvibe-sdk/community | both | createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?}): pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config, pastes.comments.list/create/delete, shared comments.resolve/get/upload/create/edit/delete/visibility, as(subject) |
| openvibe-sdk/history | both | Private history reads: cursor pages, title search, day/week summaries, recent agent reads, settings and owner-scoped deletes. |
| openvibe-sdk/home-feed | both | createHomeFeedClient(client) gives ov.home.get()/set(layout), ov.network.today(), ov.feed.list({ for: 'me', cursor }), ov.feed.post(text), and ov.feed.contribute({ id, text, url }). Personal calls need a user Bearer token; contribution needs an app token granted community.pulse.contribute. Today is public and cached by Network for five minutes. |
| openvibe-sdk/feed | both | createFeedClient(client) gives ov.feed.list({ kind, cursor }) for the public cross-site Community Feed. Pass the returned next_cursor as cursor to read the next page. |
| openvibe-sdk/views | both | createViewsClient(client): record(event \| [events]) (up to 20 per call) sends a preview, watch or live heartbeat for { service, kind, id } to POST /api/v1/views; counts(ref) reads { views, engaged, previews, preview_share, peak_concurrent }. A signed-out viewer keeps an opaque session id; the service stores no IP or user agent and counts a preview from 1 s. |
| openvibe-sdk/bot | both | createBotClient(client, {actingSubject?}): OpenVibe.Bot robots. robots.list/get/create/update/delete/pairingCode, robots.operators.list/add/remove, robots.devices, robots.streaming.get/set, robots.audit/iterateAudit, robots.estop/clearEstop, robots.command (bot.command@1), embedUrl(robotId, mode) (panel, overlay or compact), devices.rotate/revoke, public profiles.list/get and kits.list/get, pair (the agent's one-time code, never retried), as(subject); a service acts for a person with X-OV-Subject (capabilities bot.robot.read/manage/control, bot.device.connect) |
| openvibe-sdk/themes | both | createThemesClient(client): catalog({mode, search, sort, limit}) (seasonal themes carry season), get(idOrSlug) (null when missing), export(idOrSlug), active() ({theme_id, custom_variables, display}), set(themeId, {custom}), setDisplay({motion, text, seasonal}), seasons({date}), submissions(), submit(theme), import(file). In the browser: followTheme(fn) calls back now and on every theme, display or season change with readTheme() ({id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}) and returns stop(), for a canvas or game to keep up; applyTheme(vars, {root}), seasonOn(date, seasons), onColor(hex), DISPLAY_OPTIONS, TOKENS |
| openvibe-sdk/bench | both | createBenchClient(client, {actingSubject?}): kinds.list/get, tasks.create/get, rounds.create/get, entries.submit, judgements.next/cast, boards.get, ratings.get (public, at least twenty games), models.get, datasets.get/iterate, evals.create/get/run/results, mcpTools(). Audience openvibe.bench; service id bench. |
| openvibe-sdk/vision | both | createVisionClient(client): image questions, OCR, private search, network tools, UI checks, consent and face privacy, and /mcp. Audience openvibe.vision; service id vision. |
| openvibe-sdk/consent | server | createConsent({networkUrl, token, ttlMs?}): the L24 check before any use of a person's content or likeness for AI or data. allowed(subject, purpose, {kind, client, feature}) → boolean, filter(subjects, purpose, opts) → the allowed subset (batches of 500, input order), onChange(event) drops that subject's answers for the purpose on network.consent.changed, mock(map) for tests. Fails closed (network error, 2 s timeout, non-200 or malformed answer → false); caches 60 s; logs at most once a minute. [Example](docs/consent.md) |
| openvibe-sdk/search | both | createSearchClient(client, {actingSubject?}): query(text, {owner, type, lang, filter, facets, limit, cursor}), iterate(text, {…, max}), suggest(text, {owner, type, limit}), document(owner, type, id) (null when missing or not visible), code(text, opts) for merged code results or {deep: true} for a cited answer, codeIterate(text, opts) |
| openvibe-sdk/limits | node | createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?}) → limits(name, {minute?, hour?, day?}) Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else ip:), 429 problem+json rate_limited with Retry-After before the handler; defaultActor(req), .stats() |
| openvibe-sdk/account-data | node | createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?}) → .apply(event, {send}), .consumer({secrets, send, onEvent?}) (a POST /internal/events handler), .exportPart(subject), .erase(subjects), .ensureSchema(); createNetworkSender({networkInternalUrl, clientId, clientSecret}); startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret}) (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id; telemetry is covered once here (the person's telemetry_samples exported as telemetry.json, its subject set to NULL and the sample kept) unless the table is listed; uncovered(db, {tables, taskKeyed?, exempt?}) lists the tables with a person column (subject, owner, created_by, user_id, person) that nothing covers, for a service test to assert is empty |
| openvibe-sdk/chat | both | createChatClient(client): global.send/history/search, rooms.* (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), dms.* (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), activity.* (list, unread, readOne, read: what happened to the person in Chat), embeds(message) (the typed chat.embed@1 address previews on a message), exportMine(), vibe.* (list, start, rename, remove: chats with Vibe), inboxes.* (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), support.* (list, messages, send: their own conversations with sites' chats), feeds.* (mine, create, update, rotateKey, remove, view, onLive). With no account: createInboxVisitor(client, { inbox, token, user, account }) (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and createFeedPublisher(client, { feed, key }) (hello, publish, claudeCode: vibe coding events with the feed's key) |
| openvibe-sdk/ai | both | createAiClient(client): runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …}), runs.get/list/cancel/retry/citations/addCitations/waitFor, batches.create/get/list/cancel, creations() (the public catalogue of what AI can make, no token sent), chat/generate/summarize/classify/extract/enrich/embed. A run may ask for the class it may wait in (latency: interactive, background, batch; deadlineHours: 1–72 for a batch run); waitFor gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. |
| openvibe-sdk/jobs | both | createJobsClient(client, {baseUrl?}): submit (Idempotency-Key), get, cancel, retry, reference, unreference, events (SSE, reattaches), wait, file (raw Response); isTerminal(). Default origin: the Tools gateway's jobs facade |
| openvibe-sdk/agents/loop | both | The light agent loop (a port of pi's, MIT): runLoop({ model, tools, system, messages, prompts, budget, beforeToolCall, afterToolCall, transformContext, steering, followUp, finishTurn, onEvent, signal }) resolves { messages, stop, usage, cost, turns } (stop: done, terminated, budget, turns, aborted, error). Tools in parallel (results in the model's order) or in sequence; an unknown tool, refused input, a throw or a timeout comes back to the model as an error; a call cut off by the output limit never runs; repeats get a reminder; idempotent tools answer from a memo; long outputs are clipped. createStepSink({ append, update, delta, startSeq, startTurn }) turns its events into agents.step@1 steps |
| openvibe-sdk/agents/dispatch | both | The Dispatcher, as pure functions over a person's choices: ladder(choices, { need, pin, avoid, budgetLeft }) (their own machine, a signed-in plan, their key, the free allowance, then credits, cheapest first; what cannot do the step is listed in .skipped with why; a person's pick wins when it can), pick, escalate(current, evidence, choices) (only on a failed test, schema, tool or grader, or the model's own "not sure"), harnessFor(task, harnesses, { plans }), placeFor(run, { machines, cloud }), estimate(choice, tokens), slice(parentBudget, share) |
| openvibe-sdk/agents/blueprint | both | Make your own agent (agents.blueprint@1, ten parts): validate(doc) (the shape and the rules a schema cannot say: no secret in it, every {{input}} declared, level full has a budget, machine and robot tools have somewhere to run, a served tool has its shapes, a team never contains itself), withDefaults (talks only, asks first, keeps nothing), asks(doc, { describe }) (the install screen in words, with the keys agents.install@1 records), wider(old, new) (what a new version asks for that the old did not), unagreed(doc, agreed), fill(doc, inputs), compile(doc, { install, inputs, text, tryIt }) (run-start, the loop's system and tools, the Dispatcher's need, places, memory, team; Try it asks before every tool and spends at most 5¢), estimate(doc, choices) (the price of a run and where it is free), ref / parseRef |
| openvibe-sdk/agents/rules | both | Permissions as data (agents.permission-rule@1): decide(rules, call, { attended, level }) (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), fromBlueprint(doc) (only the tools it names, never .git/hooks or its own config, its sandbox and network), matches |
| openvibe-sdk/agents/router | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): question(step), probability(answer), route({ p, threshold, small, strong }), calibrate(history, { strongShare }) (the threshold that sends that share strong), outcomes(history) (the quality-cost points), chain(step, { pick, pin, ask }) |
| openvibe-sdk/agents/metrics | both | What a run measured (agents.run@1 1.4.0 metrics): createRecorder() (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), passAtK, passHatK, flakeRate |
| openvibe-sdk/ai-meter | both | Every product meters its AI calls the same way (ai.call.recorded@1 1.1.0, ai.call-report@1): startCall({ system, provider, model, run_id|turn_id, operation, task, task_id, harness, place }) → queued(ms), firstToken(), tool(), end({ usage, outcome, charged_usd, fallbacks, error_class }) (TTFT, latency, tokens per second after the first token); normalize(usage) (OpenAI chat and Responses, Anthropic, Gemini, Ollama; the uncached input); createMeterClient({ aiUrl, tokenClient }) (batches to AI's POST /api/v1/usage/calls, retries what can be retried, never resends a refused batch) |
| openvibe-sdk/agents/api | server | The Run API from a product's own store: createRunApi({ product, subject, store, prefix }) returns handle(req, res, next) for Express or any Node http server, serving GET/POST /runs, GET /runs/:id, …/steps, …/events (server-sent events, from the store's follow or by reading new steps), POST …/messages, …/stop, …/approvals/:approval. The store has list, get, steps, start, say, stop, answer, follow?; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own { status, code } errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) |
| openvibe-sdk/agents | both | createAgentsClient(client, {baseUrl?, service?, audience?}): run(id) fetches an agent run snapshot (null when missing), events(id, {lastEventId, signal}) follows the resumable AG-UI event stream, post(id, approval) answers an approval request |
| openvibe-sdk/tools | both | createToolsClient(client, {baseUrl?, anonymousReads?}): list({family, q, execution, api, status}), get(id), schema(id), run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer}) (+ .wait()), jobs; ToolRunError, isToolRunError(); prefer picks the worker placement (auto, home, host) ([details](docs/tools-routing.md)) |
| openvibe-sdk/projects | both (user token) | createProjectsClient(client): catalog, list, create, get, update, archive, setAllowance, setEnvironmentPolicy, members.*, apps.*, credentials.list/rotate/revoke, grants.list/request/approve/deny/revoke, quotas.*, audit, iterateAudit |
| openvibe-sdk/resources | server | parseResourceName/resourceName/resourceNameOf (OVRN, via the pinned contracts.resources) and RESOURCE_KINDS (only the chosen med/wch prefixes); createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs}): list({project, kind, owner, limit}) -> {resources, stale} (owner: a subject id, each authority answering only what it owns), fanning out over the authorities' GET /api/v1/resources, following each opaque next_cursor and merging common.resource-list-result@1 pages (an authority that fails — or whose cursor chain repeats, outruns maxPages, or outruns maxMs — is reported in stale, never fatal), and iterate; createResourceClient({origin, token}): control(request) POSTs a common.resource-control-request@1 to /api/v1/resources/control — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that idempotencyKey — and returns the common.resource-control-result@1 (done/pending/refused/failed, a refused/failed one returned even with a non-retryable non-2xx) |
| openvibe-sdk/geo | both | createGeoClient(client, {samples?, timeoutMs?}): nodes({role, region}) from Network's public node registry, measure(nodes) (beacon round trips, fastest first), nearest({role, region, preferRegion}) → {node, rtt_ms, measured} (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| openvibe-sdk/openre | server | createOpenReClient(client, {baseUrl?, publicUrl?}): OpenRestream, the streaming engine. streams (byExternalRef, create, get, update, rotateKey, destinations, addDestination), destinations (test, start, stop, logs), sessions (get, playback cached 10 s, end, outputs), workers, manageUrl; {subject} acts for a person (X-OV-Subject) |
| openvibe-sdk/placement | server | plan(requirements, offers, {rateCards, states, now, current, minGain, weights, success}) → platform.placement-result@1; place(requirements, offers, {…plan, person, step, floor, priors, outcomes, rules, names}) → {result, line, ranked, rejected, fallback}: the person's rules and ownership, the quality bar, cost per success and one sentence saying why; optionKey, learn, successOf, escalate, lineFor; TRUST_CLASSES, TRUST_ORDER, DEFAULT_TRUST; planAi(requirements, deployments, {enabled, usage, evidence, rates, current}) → {chosen, rejected, objective, estimate, explain}: the AI Exchange face of the same planner (hard gates, the cost per success in nano-USD, sparse evidence marked), off by default ([details](docs/placement.md)) |
| openvibe-sdk/play | server | createPlayClient(client): OpenVibe.Play's achievements and leaderboards for any game — titles.define(title, {name, url?, summary?, achievements, boards}), scores.post(title, {board, ref, entries}), achievements.unlock(title, {player, achievements}), players.adopt(title, guest, person), players.achievements(title, player); public titles.list/get and boards.get(title, board, {period, page}) need no token |
| openvibe-sdk/download | server | createDownloadClient({ key }): OpenVibe.Download, a person's drive — files.list/all/get/remove, folders.list/create/ensurePath(path), upload({ path \| data, name, folderId }) in parts, usage(), and sync(dir, { to, delete, dryRun, exclude }) backups. The openvibe-download command: sync <folder> --to <drive/path>, ls, usage (OPENVIBE_DOWNLOAD_KEY, a sync key from openvibe.download/sync) |
| openvibe-sdk/quest | server | createQuestClient(client): daily(), myDaily(), today() (authenticated current-day response), stepTypes(), contributeStepType(step) for the shared daily quest and reviewable event-backed steps (quest.steps.contribute); cardData({ token, timeZone }) for the shared quest card (today's quest, the catalog kept ten minutes, and the person's badges/quests), with the person's Network token; appQuests.define/list/retire for an app's own badge quests (quest.app_quest.manage); eventType(projectId, name) builds an app event topic. App quests pay badges only; production events count |
| openvibe-sdk/live | server | createLiveClient(client): OpenVibe.Live's documented routes. streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint, slots.list/create/update/remove/regenerateKey (a slot is a saved setup with its own stream key), goLive(slotId, {title, category, tags}) → { stream, endpoint }, channels.get/mine/update/toggleFollow/url, content.feed/moments({type, sort, window, cursor, limit}); WHIP: whip.url(streamKey), whip.publish(url, offerSdp, {key?}) → { answer, location } (the client's own token never goes to /whip), whip.end(location). Writes are never retried |
| openvibe-sdk/streams | server | createStreamsClient(client, {baseUrl?}): an agent's own stream on OpenVibe.Live (the Agent Streams plan, S1; live.agent-stream@1, an ags_… id with no stream key and no slot). startAgentStream({title, task, visibility, allowSites}) → {stream, publish} (the WHIP url and the one-time wpt_… ticket with its expiry), renewTicket(id) and revokeTicket(id) (the kill switch: Live ends the WHIP session at once), state(id, {ticket}) (what the desk polls: viewers, encode, the curtain, ends_at), postFrame(id, bytes, {ticket, contentType}) (the last frame while Live wants no video, JPEG or WebP at most 200 KB), setCurtain(id, up, reason), setVisibility(id, v), end(id) and viewTicket(id). The publish ticket is a bearer of its own on the desk routes; a bad id, visibility, curtain reason or an oversized frame is refused before anything leaves. [Docs](docs/streams.md) |
| openvibe-sdk/inventory | server | createInventoryClient(client): OpenVibe.Inventory, the network-wide inventory (ADR-054). Public reads: kinds.list/get, definitions.list({kind, issuer})/get, people.items(subject), people.holdings(subject, opts), people.holdingsCount(subject, opts), people.holdingPages(subject, opts), people.equipped(subject), equipped([subjects]) (≤ 100, for chat renders). The person's own: me.items(), me.equip({kind, slot, instance_id | null}). An issuer's: definitions.create/update, grants.create({definition_id, subject, idempotency_key}) → { instance, created } (retry-safe on the key), instances.consume/revoke. forPerson(usr_…) sends X-OV-Subject for a service acting for a person. Audience openvibe.inventory |
| openvibe-sdk/tips | server | createTipsClient(client): OpenVibe.Tips' /api/v1. A creator's page: profiles.get/update/totals/sounds/supporters. Tipping: tip({creator, amount, message?, goal_id?, pay_with}) → { interaction, checkout_url, checkout_ref }, send.message/tts/media/sound. Receipts: interactions.get/list/recordExternal/hide/restore. goals.list/get/create/update/close, moderation.queue/log/moderators.*, overlays.tokens.*/configs.*/simulate, me.export/erase. Every write carries an Idempotency-Key, which Tips replays once, so retries are safe. Audience openvibe.tips |
| openvibe-sdk/blog | server | createBlogClient(client): OpenVibe.Blog for the person who authorized the app. blogs.get/posts(handle, {limit, offset, all})/feed (JSON Feed), changelog({service, kind, area, q, limit, before}) (the network changelog's blog.changelog-entry@2 rows), posts.create(handle, {title, body, summary, visibility, …}) → { post, revision }, posts.get/update(id, {…, expected_revision})/publish(id, {revision})/unpublish/schedule(id, {at, revision, action})/cancelSchedule/revert/remove/revisions/revision(id, n). Public capabilities since openvibe-contracts 0.206.0; publish, unpublish, schedule and delete are sensitive. Creating and reverting are never retried on their own. Audience openvibe.blog |
| openvibe-sdk/reviews | server | createReviewsClient(client): OpenVibe.Reviews. resolve({name, url, gtin, sku, mpn, source, external}) (a match or candidates; a name alone never matches), entities.list({q, limit, offset})/get(ref)/history/aggregate/summaryRevision(ref, n), corrections.submit(ref, {body, target_type, target_id, evidence_url}) (to the editors' queue for the person, never retried on its own). Public capabilities since openvibe-contracts 0.207.0. Audience openvibe.reviews |
| openvibe-sdk/wiki | server | createWikiClient(client): OpenVibe.Wiki's reads. search(q, {space, limit}), spaces.list/get(space), pages.get(id, {revision})/revisions(id, {before, limit})/revision(id, n)/diff(id, {from, to, mode})/citations(id, n): what the person may read (without one, public pages). Public capabilities since openvibe-contracts 0.207.0. Audience openvibe.wiki |
| openvibe-sdk/coupons | server | createCouponsClient(client): OpenVibe.Coupons. merchants.resolve(host)/get(id)/coupons(id) and get(cpn_…) (public data), submit({code, title, merchant_id \| host \| url, evidence_url, …}) (sensitive; never retried on its own) and report(cpn_…, {outcome, reason}) for the person. Public capabilities since openvibe-contracts 0.208.0. Audience openvibe.coupons |
| openvibe-sdk/deals | server | createDealsClient(client): OpenVibe.Deals. Public: offers.list({sort, page})/get/hotness, products.get, stores.get. For the person: offers.submit (sensitive), update, observe (a new price observation), vote(id, 1 \| -1) (sensitive), unvote, flag(id, {kind, reason}), watches.list/create/remove. Public capabilities since openvibe-contracts 0.208.0. Audience openvibe.deals |
| openvibe-sdk/trade | server | createTradeClient(client): OpenVibe.Trade. instruments.resolve(q)/list/get/observations(symbol, {metric, before, limit})/documents, sources(); the person's watchlists.list/get/create/rename/add/removeItem/remove and alerts.list/deliveries/create/remove. Public capabilities since openvibe-contracts 0.208.0. Audience openvibe.trade |
| openvibe-sdk/space | server | createSpaceClient(client): OpenVibe.Space, static sites for apps and CI. deploys.uploadArchive(site, tarGz, {activate, preview, root}), uploadFiles(site, [{path, data}], …), uploadSourceBuild(site, archive, {ref, commit_sha}), list/get/log/activate/rollback(site, {deploy_id})/remove; projects.list/create/get/remove/quota/sites/members.set(p, principal, role)/remove; sites.create/get/remove, sites.config.get/set/reset ({ headers, redirects, spa }), sites.source.get/set/remove; domains.list/add/verify/remove. An app reaches only the projects a person added it to (deployer, maintainer or owner). Public capabilities since openvibe-contracts 0.209.0. Audience openvibe.space |
| openvibe-sdk/host | server | createHostClient(client): OpenVibe.Host's public hosting catalog, sent without a token. providers.list/get(slug), plans.list({kind, min_vcpu, min_ram_mb, min_disk_gb, max_price, currency, location, provider, sort, cursor})/get(id), promos.list, offers.list({sort, currency, max_monthly, source, priced, cursor}), domains.list/tld('dev')/check('example.dev'), benchmarks.list/get. Any other filter is refused (422). Editing the catalog is first-party |
| openvibe-sdk/redact | any | The shared secret scrubber (Memory M2): redact(text) → { text, count } removes the tool and service keys, PEM private key blocks (including one with no END line), authorization headers, bearer tokens, chat webhooks, connection-string passwords, credential-named assignments and Luhn card numbers, keeping the words around them (password=[redacted]); onlySecret(text) says an item was nothing but a secret. The same shapes and corpus as OpenVibe Link's internal/memory/redact.go, so Link and AI cannot drift |
| openvibe-sdk/explicit | any | isExplicit(...texts): true when AI text says a scene shows sexual content (body parts, sex acts, pornography, nudity); such a moment, clip or paste is not published |
| openvibe-sdk/no-looks | any | Deprecated (2026-10-31): AI text may describe people. noLooks, noLooksTags and hasLooks return what they are given; isExplicit is re-exported from openvibe-sdk/explicit |
| openvibe-sdk/front-page | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): frontPageOk(text) is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; containsCoreSlur(text) is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page |
| openvibe-sdk/news | server | createNewsClient(client): OpenVibe.News' published stories, sent without a token. topics(), stories.list({topic, limit, offset}) (published and retracted, newest first) and stories.get(id) (sources, timeline, perspectives). Writing and the editorial reads stay first-party |
| openvibe-sdk/food | server | createFoodClient(client): OpenVibe.Food. Public: places({kind, q, lat, lon, radius}) (food banks, pantries, free meals from OpenStreetMap), foods.list/get (the food list with store prices). The caller's own: plans.create({people, days, budget, title})/list/get (deterministic meal plans with a shopping list and costs), pantry.get/set(items) (with "what can I make" matches), recipes.suggest({ask, people})/list (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience openvibe.food |
| openvibe-sdk/chat-apps | server | The three chat apps (Telegram, Discord, Slack) in one place: verifyTelegram(), verifySlack(), verifyDiscord(), verifyDelivery() (the delivery's own proof), signCallback() / verifyCallback() (a button callback: signed once, used once, expiring), parseTelegramUpdate(), parseDiscordInteraction(), parseSlackEvent(), parseSlackInteraction() (each app's payload as the same shape: who said it, where, whether it was said to the bot) and splitText() (an answer split the app's way) |
| openvibe-sdk/actor | server | createActorClient(client): OpenVibe.Actor. agents.list() and route({task, mode}) (a dry run) are public. tasks.create({task, mode, budget, agent, idempotency_key, webhooks}) (retried only with an idempotency_key; webhook_secret comes back once), tasks.list/get/cancel, tasks.events(id, {lastEventId}) (SSE until the task ends). A person's own agents: me.agents.list/create/get/update/remove/run/runs and me.agents.memory.list/add/update/remove/clear. Audience openvibe.actor |
| openvibe-sdk/menu | server | menuCard({id, site, title, kind, items, more, origin, ttlSeconds, updatedAt}): one validated frame.menu-card@1 — plain text cut to the contract's lengths, at most six items (the contract's cap), https links on the site's own origin or an openvibe.* host, an optional item image ({src, w, h, alt}, a picture from elsewhere is dropped), and more ({label, url}). menuRoute(getCards, {maxAgeSeconds = 300, origin, log}): the Express/node:http GET /.well-known/openvibe-menu.json handler, answering an array of at most three cards with Cache-Control: public, max-age=… and CORS *; a getCards() failure is [] (never a 500) and logs once. MENU_PATH |
| openvibe-sdk/run | server | createRunClient(client, {project?}): OpenVibe.Run, jobs on worker nodes. jobs.submit({class, artifact, args, limits, …}) (Idempotency-Key, so retried safely) → { created, job }, jobs.list({state, limit, cursor}), jobs.get/cancel, jobs.events(id, {lastEventId}) (SSE until end), jobs.streamTicket(id) (a one-use URL for a browser's EventSource); forProject(prj_…) sends X-OV-Project. Audience openvibe.run |
| openvibe-sdk/video | server | createVideoClient(client): OpenVibe.Video's playlists of recordings and clips. playlists.list/create/get/update/remove, playlists.items.add/reorder/remove ({ kind: 'vod' \| 'clip', id }), users.playlists(username) (public). Private playlists are 404 to everyone but their owner. Audience openvibe.download (the media hub's) |
| openvibe-sdk/work | server | createWorkClient(client): OpenVibe.Work. Public: jobs.search({q, remote, type, location, cursor}), jobs.get, sources(). A person's: savedSearches.list/create/remove, applications.list/create/get/update/remove (also an app with work.application.manage; tracking a listing twice answers the first), applications.assist(id, {ask}) (their Actor agent helps). Audience openvibe.work |
| openvibe-sdk/zone | server | createZoneClient(client): OpenVibe.Zone, a project's object zones in Media. zones.create(prj_…, {env, name, description, quota_bytes}) (Idempotency-Key), zones.list(prj_…, {env, include_deleted}), zones.get/update/remove(zon_…, {recursive}), zones.usage(zon_…). Project members and the project's apps only. Audience openvibe.zone |
| openvibe-sdk/vip | server | createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?}): evaluate({subject, resource, owner, fallback, mode}), checkEntitlement({subject, creator, product, mode}), isMember(subject, creator); every failure is a denial. createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs}): entitlement, peekEntitlement, evaluate, invalidate, handleEvent (vip.membership.changed and Billing's entitlement events drop a member's answers at once), clear, bounds |
| openvibe-sdk/usage | server | usageSample(fields) builds a platform.usage-sample@1 record (required id, idempotency_key, service, operation, quantity, unit; at defaults to now ISO, source to openvibe-sdk/usage; null fields stripped; money fields never invented); usageKey(service, ...parts) joins an idempotency key with :; validateUsageSample(record) → {ok, errors} (openvibe-contracts, required lazily). createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... }) owns one service's readings: key(...parts)/sample(fields), and record(t, reading) queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's billing.usage.record with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass requireProject: true to refuse a reading without a prj_… project). openvibe-sdk/govern builds its onUsage record with it |
| openvibe-sdk/commerce | server | createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?}): the Billing client Tips and VIP share. config = {billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}; caps maps intent, transfer, subscription, entitlement (and optional rates, default entitlement) to capability strings, one lazily created serviceAuth token client per distinct string (scope = the string) unless tokenClients supplies one, keyed by logical key or capability. Methods: createIntent (kind purchase with bits, or subscription with creator/tier/autoRenew), createTransfer, refundTransfer, subscribeWithCredit (tier), cancelSubscription, getSubscription, listSubscriptions, entitlement, rates, listTiers(creator) and setTier({creator, tier, priceCents, status?, key}) (a creator's tier prices, kept by Billing; optional caps.tier = billing.tier.manage, which setTier needs), baseUrl. Idempotency-Key is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes CommerceError (status, code, body, retryable). intentKey(prefix, parts) joins the parts that are set; receipt(kind, fields) → {kind, at, ...fields} (null stripped, never posted). openvibe-contracts is required lazily |
| openvibe-sdk/vibes | server | createVibesClient({appToken \| tokenProvider, billingUrl, fetchImpl?, timeoutMs?}): Vibes Pay for developer apps (D12). charges.create({amount, description, reference, return_url}) sends a whole number of Vibes with the reference as the Idempotency-Key, → {id, pay_url, status, expires_at}; charges.get(id), charges.refund(id, {reason?}). webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?}) checks X-OpenVibe-Signature-V2 (t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">, constant time, replay window) and the legacy sha256=<hex> body-only form; also exported as verifyWebhook. A float, zero or negative amount is refused before any request; problem+json becomes VibesError (status, code, body, retryable), a 401 invalidates a token provider and retries once with the same key |
| openvibe-sdk/exchange | server | createExchangeClient({developerKey, baseUrl?, networkUrl?, siteUrl?, fetchImpl?, timeoutMs?, maxRetries?, owner?, paths?}): OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, item 7). Buyer: chat(body, {signal}) → the OpenAI shape (stream: true answers an async iterable of chunks), models(), offers(model) (the offers that serve one model, with the price, the speed and the uptime), catalog(), presets.list/create/update/remove (a preset is stored under its name; there is no PATCH), usage({days}) (the public aggregate; with from/to/requester the platform usage for that requester) and receipt(id). Seller: seller.offer({model, price, hours, concurrency, endpoint, machineId, owner}) registers the offer in the Network’s user-owned AI offers (the same registry OpenVibe Link’s "Offer to the network" writes to: POST /internal/ai/offers/report, the capability network.resource.report), seller.earnings(), seller.verification() (canaries, spot re-runs, reputation, uptime) and seller.offers(). A 429 or a 5xx is retried with a jittered backoff (a write only when it is idempotent); every call takes an AbortSignal. webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?}) checks X-OpenVibe-Signature-V2 (t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">, constant time, replay window) and the legacy sha256=<hex> body-only form; also exported as verifyWebhook. Compute: computePrices({kind: 'cpu' | 'gpu'}) reads the reviewed GPU/computer tariffs (per hour, per second, checked and review dates, the provider's price page) from Exchange's public site, keyless; createComputePriceClient({baseUrl?, fetchImpl?}) is that reader alone |
| openvibe-sdk/telemetry | server | telemetrySample(fields) / validateTelemetrySample(record) for platform.telemetry-sample@1; createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000}) → {record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}: buffered samples flushed to await sink(samples) on an unref'd interval and on stop() (a gracefulStop stop step); record builds {service, instance, operation, at, latency_ms, ...labels} (a non-number value goes to extra[name]); a rejecting sink is logged once and its samples kept; past maxBuffered the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: telemetrySchema(table = 'telemetry_samples') (migration DDL), createPgTelemetrySink({db, table?, log?}) (one multi-row INSERT per flush, malformed samples dropped with a warning), pruneTelemetry({db, table?, olderThanDays = 30}), startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?}) → {run(), stop()} (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) |
| openvibe-sdk/cost | server | Build and validate platform.rate-card@1 and platform.cost-snapshot@1 records with rateCard(), costSnapshot(), validateRateCard(), and validateCostSnapshot(); estimate() calculates a charge from a rate card; the AI Exchange money math in integer nano-USD with nanoUsd(), chargeTokens(), expectedCostNano(), costPerSuccessNano(), formatNanoUsd() |
| openvibe-sdk/runtime | server | Build and validate platform.runtime-offer@1 records with runtimeOffer() and validateRuntimeOffer(); runtimeClass(), RUNTIME_CLASSES, and validateRuntimeClass() handle known runtime classes |
| openvibe-sdk/storage | server | storageOffer() and validateStorageOffer() for platform.storage-offer@1 |
| openvibe-sdk/delivery | server | deliveryOffer() and validateDeliveryOffer() for platform.delivery-offer@1 |
| openvibe-sdk/link | server | linkCapabilities() and validateLinkCapabilities() for platform.link-capabilities@1; linkOffers() derives resource and runtime offers from a node descriptor |
| openvibe-sdk/service | server | the service kit: gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles}) → {stop, stopping}, within(ms, p); createServiceError(name), ServiceError, asServiceError, run, wrap, sendError, jsonBody({limit}), privateNoStore, jsonErrors(); the HTTP telemetry kit: createHttpTelemetry({service, sink, intervalMs?, routeLabel?, skipPrefixes?, …}) → {middleware(), record, gauge, count, requestStarted, requestFinished, flush, stop}, createTelemetryMiddleware, telemetrySkipped, defaultRouteLabel, registerSignals, telemetrySample, validateTelemetrySample, and the singleton telemetry.{init, record, gauge, count, requestStarted, requestFinished, flush, stop} + telemetryMiddleware (a request is aggregated per route\|method\|status_class per flush into one platform.telemetry-sample@1 http.request sample, plus active-requests/p95/event-loop-lag gauges once per flush); re-exports (loaded on first use) of openvibe-shared createReadiness, skip, safeReason, createRegistry, instrument, metricsHandler, isLoopbackDirect, releaseInfo, createRelease and openvibe-contracts problem, sendProblem |
| openvibe-sdk/testing | Node | createMockPlatform(): fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process fetch |
| openvibe-sdk/browser/openvibe-sdk.mjs | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects |
Server-only subpaths are declared "browser": null in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.
How every call behaves (core)
- Where it goes. Name a
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. - 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.
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
}
[Worker placement and execution receipts](docs/tools-routing.md): tools.run(tool, input, { prefer }) and tools.jobs.get(id).
- What
run()resolves to. An inline tool, or a job that finished 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](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.
The OpenVibe camera element
The browser bundle also offers ov.cam.open() for video, photo or screen capture in place. It loads Cam's
SRI-pinned recorder once, then gives your page a File and metadata after the person reviews and chooses Use this:
import ov from 'openvibe-sdk';
const result = await ov.cam.open({ mode: 'video', maxSeconds: 60 });
if (result) await upload(result.file);
Closing Cam resolves to null. The calling site owns the file and upload. openvibe-sdk/cam also exports
open, loadCamKit, CAM_KIT_SRC and CAM_KIT_SRI for direct use.
capture(options) is the direct camera stream, on OpenVibe's one shared capture core: it loads the calling
site's own /shared/ov-capture.js once (every OpenVibe site serves it) and calls OVCapture.open(options),
so facing, lens, mic, resolution, audio and video behave the same as Live's go-live page:
import { capture } from 'openvibe-sdk/cam';
const stream = await capture({ mode: 'camera', facing: 'environment', resolution: '1080' });
video.srcObject = stream;
openvibe-sdk/cam loads the camera app's framework-free <ov-camera> element from OpenVibe.Cam and types its
attributes, events and methods. It injects the embed script once and resolves to the element class:
import { defineOvCamera } from 'openvibe-sdk/cam';
await defineOvCamera(); // https://openvibe.cam/embed/ov-camera.js, once
const camera = document.querySelector('ov-camera');
camera.addEventListener('ov-camera:capture', (event) => {
// event.detail = { blob, width, height, filter }; nothing was uploaded
upload(event.detail.blob);
});
await camera.start(); // or let the person click the element's own button
<ov-camera facing="user" filters="none,mono,warm" timer="3" mode="photo" upload="off"></ov-camera>
upload="off" (the default) keeps the picture in the page. upload="openvibe" posts it to POST /api/v1/photos as
the signed-in person, and only on openvibe.cam. defineOvCamera({ src }) loads a self-hosted copy of the same file.
The full attribute, event and method reference is at openvibe.cam/developers.
Put your content in the OpenVibe menu
Every open site can put its own cards in the network menu every site shares. Build the card with
menuCard (it applies the house rules and validates against frame.menu-card@1), then mount the
route — Express or plain node:http:
const { menuCard, menuRoute } = require('openvibe-sdk/menu');
const cards = async () => [menuCard({
id: 'new-worlds', site: 'games', title: 'New worlds', kind: 'rows',
origin: 'https://openvibe.games',
items: worlds.slice(0, 6).map((w) => ({ title: w.title, meta: `by ${w.ownerName}`, url: `https://openvibe.games/worlds/${w.id}` })),
more: { label: 'All worlds', url: 'https://openvibe.games/worlds' },
})];
app.get('/.well-known/openvibe-menu.json', menuRoute(cards, { maxAgeSeconds: 300 }));
items may be empty (the card is valid and Network skips it until something is there). origin is
the site's own https origin for the link rule; without it, the host of more.url is used, and with
neither only openvibe.* links pass. A bad field throws a TypeError naming it. menuRoute calls
getCards() on every request, validates each card, and caps the file at three cards; it answers []
and logs once rather than a 500 when the build fails or a card does not validate, so a transient
error cannot take the site's file down. OpenVibe.Network fetches this file every five minutes and
shows the cards in GET /api/frame.
Versioning
openvibe-sdkfollows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag.- The 1.0 core surface is already frozen.
core,auth,events,dbandmediaare stable now, before 1.0: removing or renaming one of their exports needs a major release.docs/one-point-oh.jsongives each name its tier (frozen, platform, leaving),test/surface.test.jspins the exact export list of the five entries, andnode scripts/surface.js --checkmarks a break there as[1.0 core]. - [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …).
test/surface.test.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](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 (temporarily the sharing branch), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency. - A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed.
Not wrapped yet (intentionally)
- Community other-site comments and forum API remain outside this SDK surface; shared typed comment thread routes are wrapped by
community.comments. - Media: the v1 files API, the object API v2 (
createObjectsClient) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (openvibe-sdk/testing) models Media's v1 files only, not v2 objects. - Media from the browser: Media refuses user JWTs on the v1 files and v2 objects APIs, so a browser uploads through its own app server. That server holds the app credential and names the user with
actingUserIdor a subject. - Network coins, notifications and legacy-map writes are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
- Billing and Games from the original charter come when those services publish their capabilities in Contracts (Live has its module since v0.52.0). Codes' tasks (they run code on a person's own machine) stay first-party, and OpenVibe.Watch is not built yet; Host and News have modules for their public reads, while editing stays first-party. Blog opened its posts to apps (openvibe-contracts 0.206.0,
openvibe-sdk/blog), then Reviews and the Wiki their reads (0.207.0), then Coupons, Deals and Trade what a person does for themselves (0.208.0), then Space its sites (0.209.0). News' published stories need no capability, but its editorial reads stay first-party. Chat is wrapped over REST as the token's person (openvibe-sdk/chat, v0.11.0), but it has no app principal (chat.message.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](SECURITY.md). Browser-safe files never touch a client secret
(the browser secret scan covers the bundle); secrets and tokens are never logged; a write is retried
only with an idempotency key; webhook verification checks signature v2 within ±300 s (requireV2 refuses a
v1-only delivery); the Frame loads only from an https origin (or http://localhost in development). The
package has no runtime dependencies.
Deploy
Nothing is deployed from this repository: it is a package. A release is a git tag (vX.Y.Z, with its
[CHANGELOG.md](CHANGELOG.md) entry); services and apps pin the tag's tarball and ship it with their own
deploys (`sud