openvibers/openvibe.sdk Commits

openvibe-sdk/streams: the Agent Streams S1 client (0.91.0) (#125)

OP OpenVibers committed 7 days ago · 58b0b2b900 · 17 files, +519 −21

* Add openvibe-sdk/streams: the Agent Streams S1 client (0.91.0)

An agent's own stream on OpenVibe.Live (live.agent-stream@1): startAgentStream returns the stream and its one-time wpt_ publish ticket, renewTicket and revokeTicket drive the ticket (revoking is the kill switch), state is the desk's poll and postFrame its snapshot while Live wants no video, setCurtain raises the privacy screen, setVisibility switches who may watch, end ends the stream and viewTicket mints the player's ten-minute ticket. The publish ticket is a bearer of its own on the desk routes; ids, visibility, curtain reasons and frame size are checked before anything leaves. Pin openvibe-contracts v0.298.0, re-copy the claims type, and add the docs, types, ESM entry, surface and pack entries.

* Merge main into the SDK streams branch (subject assertions released as 0.90.0)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4203f46..46fbd66 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,15 @@
# Changelog
++## 0.91.0 (2026-11-06)
++
++- `openvibe-sdk/streams`: the Agent Streams plan, stage 1 — an agent's own stream on OpenVibe.Live (a stream of kind `agent`, id `ags_…`, no stream key and no slot, published with a ticket instead of a key).
++ - `startAgentStream({ title, task, visibility, allowSites })` answers `{ stream, publish }`: the stream (live.agent-stream@1) and the publish part (live.agent-stream-publish@1) with the WHIP url, the publish ticket (shown once; Live keeps only its hash) and the moment it dies.
++ - `renewTicket(id)` and `revokeTicket(id)`: the ticket lives four hours (the desk renews it at three) and revoking it is the kill switch — Live ends the WHIP session at once.
++ - `state(id, { ticket })` is what the desk polls every ten seconds: `{ viewers, encode, curtain, ends_at }`. While `encode` is false the desk stops encoding and sends a snapshot every five seconds with `postFrame(id, bytes, { ticket, contentType })` (JPEG or WebP, at most 200 KB).
++ - `setCurtain(id, up, reason)` raises the desk's privacy screen (while it is up Live pauses every viewer's consumer, so a viewer sees "Private screen" and never why), `setVisibility(id, v)` switches who may watch, `end(id)` ends the stream (the card stays, with the last frame and the VOD) and `viewTicket(id)` mints the player's view ticket (ten minutes).
++ - The publish ticket is a bearer of its own: on the desk's routes (`state`, `postFrame`) the desk sends the ticket, never the person's token. Every body is checked in the module before it leaves, and a bad id, visibility, curtain reason or an oversized frame is refused with a TypeError.
++ - The capability behind it is `live.agent_stream.publish` for the audience `openvibe.live` (Contracts 0.298.0): a Node principal acting for its owner, Codes or Actor. Never a developer-app token, never another person's stream.
++
## 0.90.0 (2026-11-06)
- **Subject assertions** (ADR-003, the 2026-11-06 amendment; plan H1). A money call carries a Network-signed
ddiff --git a/README.md b/README.md
iindex 2a451ff..952a657 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.90.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.
++**Status:** alpha, v0.91.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.90.0 is tagged:
++After v0.91.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -195,6 +195,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried |
++| `openvibe-sdk/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.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), `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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex f5a8135..ba8d00b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,11 +3,11 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.90.0",
++ "version": "0.91.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
-- "contracts": "openvibe-contracts v0.297.0",
++ "contracts": "openvibe-contracts v0.298.0",
"features": [
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
@@@ -41,7 +41,8 @@
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
-- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps"
++ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps",
++ "streams (openvibe-sdk/streams): the Agent Streams plan S1 — start an agent stream on OpenVibe.Live (live.agent-stream@1) with its publish ticket, renew and revoke that ticket (the kill switch), poll state, post the last frame, set the curtain, switch visibility, end it and mint the view ticket; the publish ticket is a bearer of its own on the desk routes"
],
"updated": "2026-11-06"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex c364cc1..18755a9 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.90.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.91.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.90.0';
++const SDK_VERSION = '0.91.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 1f57b97..6567340 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -953,6 +953,9 @@
"storageOffer": "function",
"validateStorageOffer": "function"
},
++ "openvibe-sdk/streams": {
++ "createStreamsClient": "function"
++ },
"openvibe-sdk/telemetry": {
"createPgTelemetrySink": "function",
"createTelemetry": "function",
ddiff --git a/docs/streams.md b/docs/streams.md
nnew file mode 100644
iindex 0000000..0220817
---- /dev/null
++++ b/docs/streams.md
@@@ -0,0 +1,70 @@
++# The Agent Streams (S1)
++
++`openvibe-sdk/streams` drives an agent's own stream on OpenVibe.Live: the Agent Streams plan, stage 1.
++
++An agent stream is a Live stream of kind `agent` (its id starts `ags_`). It never has a stream key and never takes a
++normal slot; it is published with a **publish ticket** instead, and the ticket is what opens the door.
++
++```js
++const { createClient } = require('openvibe-sdk/core');
++const { createStreamsClient } = require('openvibe-sdk/streams');
++
++const streams = createStreamsClient(createClient({ tokenProvider }));
++
++const { stream, publish } = await streams.startAgentStream({
++ title: 'Fixing the login page',
++ task: { source: 'codes', ref: 'task_01JAB…', title: 'Fix the login page' },
++ visibility: 'private',
++});
++// publish.whip_url, publish.ticket (shown once), publish.expires_at
++```
++
++## The desk's loop
++
++The desk (OpenVibe.Node) polls Live every ten seconds and keeps publishing while Live wants video:
++
++```js
++const s = await streams.state(stream.id, { ticket: publish.ticket });
++// { viewers, encode, curtain: { up }, ends_at }
++
++if (!s.encode) await streams.postFrame(stream.id, jpegBytes, { ticket: publish.ticket });
++```
++
++While `encode` is false the desk stops encoding and sends a snapshot every five seconds (JPEG or WebP, at most
++200 KB). While the curtain is up Live pauses every viewer's consumer, so a viewer sees "Private screen" and never why.
++
++## The privacy screen
++
++```js
++await streams.setCurtain(stream.id, true, 'password_field'); // up: a password field is focused
++await streams.setCurtain(stream.id, false, 'password_field'); // down: the detectors are clear again
++```
++
++The curtain comes down only from the desk (after its detectors report clear for two seconds) or from the owner's own
++button. A detector that fails or is unsure puts the curtain up and never down. The reason is the owner's only.
++
++## The ticket
++
++The ticket lives four hours; the desk renews it at three.
++
++```js
++const again = await streams.renewTicket(stream.id); // a new ticket; the old one stops working
++await streams.revokeTicket(stream.id); // the kill switch: Live ends the WHIP session at once
++```
++
++The ticket is a bearer of its own: on the desk's routes (`state`, `postFrame`) the desk sends the ticket instead of
++the person's token, so Live never sees the person's token there.
++
++## Watching
++
++```js
++await streams.setVisibility(stream.id, 'unlisted'); // private | unlisted | public
++const { ticket: view } = await streams.viewTicket(stream.id); // wvt_…, ten minutes
++await streams.end(stream.id); // the card stays, with the last frame and the VOD
++```
++
++## Who may call
++
++The person's own session token, or a first-party service token acting for that person: a Node principal acting for its
++owner (Network issues the node token with `live.agent_stream.publish` for the audience `openvibe.live` and names its
++owner in `on_behalf_of`), Codes or Actor. Never a developer-app token, never another person's stream.
ddiff --git a/esm/streams.mjs b/esm/streams.mjs
nnew file mode 100644
iindex 0000000..bc0c5d1
---- /dev/null
++++ b/esm/streams.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/streams.js. Do not edit.
++import cjs from '../src/streams.js';
++
++export const {
++ createStreamsClient,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex fa82d74..7ad4a7e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.90.0",
++ "version": "0.91.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.90.0",
++ "version": "0.91.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -14,7 +14,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.297.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
-- "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
++ "version": "0.298.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
++ "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex b84937b..d5bd3be 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.90.0",
++ "version": "0.91.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -299,6 +299,12 @@
"import": "./esm/live.mjs",
"default": "./src/live.js"
},
++ "./streams": {
++ "types": "./types/streams.d.ts",
++ "browser": null,
++ "import": "./esm/streams.mjs",
++ "default": "./src/streams.js"
++ },
"./inventory": {
"types": "./types/inventory.d.ts",
"browser": null,
@@@ -526,7 +532,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex d240e38..7666986 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -68,6 +68,7 @@ const ENTRIES = {
'download.mjs': '../src/download.js',
'quest.mjs': '../src/quest.js',
'live.mjs': '../src/live.js',
++ 'streams.mjs': '../src/streams.js',
'inventory.mjs': '../src/inventory.js',
'tips.mjs': '../src/tips.js',
'blog.mjs': '../src/blog.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 341e018..cb882d7 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.90.0';
++const SDK_VERSION = '0.91.0';
module.exports = {
createClient,
ddiff --git a/src/streams.js b/src/streams.js
nnew file mode 100644
iindex 0000000..d58778d
---- /dev/null
++++ b/src/streams.js
@@@ -0,0 +1,202 @@
++'use strict';
++/**
++ * openvibe-sdk/streams — the Agent Streams plan (S1): an agent's own stream on OpenVibe.Live.
++ *
++ * An agent stream is a Live stream of kind 'agent' (id ags_…): it has no stream key and takes no slot, and it is
++ * published with a ticket (wpt_…) instead of one. The desk (OpenVibe.Node) or a person's browser (a Codes task page)
++ * creates it, keeps the ticket and publishes through it; the owner watches the stream on the page Live answers with.
++ *
++ * const streams = createStreamsClient(createClient({ tokenProvider }));
++ * const { stream, publish } = await streams.startAgentStream({
++ * title: 'Fixing the login page',
++ * task: { source: 'codes', ref: 'task_…', title: 'Fix the login page' },
++ * visibility: 'private',
++ * });
++ * await streams.postFrame(stream.id, jpegBytes, { ticket: publish.ticket }); // a snapshot while Live wants no video
++ * const s = await streams.state(stream.id, { ticket: publish.ticket }); // { viewers, encode, curtain, ends_at }
++ * await streams.setCurtain(stream.id, true, 'password_field'); // the desk's privacy screen
++ * await streams.renewTicket(stream.id); // the ticket lives four hours
++ * await streams.end(stream.id);
++ *
++ * The publish ticket is a bearer of its own: on the desk's routes (the state poll and the frame upload) the desk sends
++ * the ticket instead of the person's token, so Live never sees the person's token there. The ticket is shown once and
++ * Live keeps only its hash.
++ */
++const AGENT_STREAM_ID = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
++const VISIBILITIES = ['private', 'unlisted', 'public'];
++const TASK_SOURCES = ['codes', 'actor', 'desk', 'node'];
++const CURTAIN_REASONS = ['password_field', 'secret_file', 'env_print', 'manual', 'site', 'detector'];
++const FRAME_TYPES = ['image/jpeg', 'image/webp'];
++const MAX_FRAME_BYTES = 200 * 1024;
++
++/** The frame's bytes, whatever the caller has: a Uint8Array, a Buffer or an ArrayBuffer. */
++function frameBytes(v) {
++ if (v instanceof ArrayBuffer) return new Uint8Array(v);
++ if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
++ throw new TypeError('streams: a frame is bytes (a Uint8Array or an ArrayBuffer)');
++}
++
++/**
++ * createStreamsClient(client, { baseUrl }) — the agent-stream routes of OpenVibe.Live.
++ * The client is an openvibe-sdk/core client; its token is the person's (or the service's) token.
++ */
++function createStreamsClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createStreamsClient: pass an openvibe-sdk/core client');
++ const at = { service: 'live', baseUrl, audience: 'openvibe.live' };
++ // A write is never retried on its own: Live keys no agent-stream route by Idempotency-Key, and a second start is
++ // a second stream.
++ const call = ({ method = 'GET', ...opts }) => client.json({ ...at, method, ...(method === 'GET' ? {} : { idempotencyKey: false }), ...opts });
++ const streamId = (v) => {
++ const id = String(v || '');
++ if (!AGENT_STREAM_ID.test(id)) throw new TypeError('streams: an agent stream id is ags_…');
++ return id;
++ };
++ const text = (v, max, what) => {
++ if (typeof v !== 'string' || !v.trim() || v.length > max) throw new TypeError(`streams: ${what} is 1–${max} characters`);
++ return v.trim();
++ };
++ const oneOf = (v, allowed, what) => {
++ if (!allowed.includes(v)) throw new TypeError(`streams: ${what} is ${allowed.join(', ')}`);
++ return v;
++ };
++ const uri = (v, what) => {
++ if (typeof v !== 'string' || !v.trim()) throw new TypeError(`streams: ${what} is a URI`);
++ try { new URL(v); } catch { throw new TypeError(`streams: ${what} is a URI`); }
++ return v;
++ };
++
++ return {
++ /**
++ * Start an agent's stream → { stream, publish }: the stream (live.agent-stream@1) and the publish part
++ * (live.agent-stream-publish@1) with the WHIP url, the publish ticket and the moment it dies. The ticket is
++ * shown once; keep it, and renew it before it expires (it lives four hours).
++ *
++ * @param {{ title: string, task: { source: 'codes'|'actor'|'desk'|'node', ref: string, title: string, url?: string },
++ * visibility: 'private'|'unlisted'|'public', allowSites?: string[] }} request
++ * @param {{ signal?: AbortSignal }} [options]
++ * @returns {Promise<{ stream: object, publish: { whip_url: string, ticket: string, expires_at: string } }>}
++ */
++ async startAgentStream(request = {}, { signal } = {}) {
++ const r = request || {};
++ const task = r.task || {};
++ const body = {
++ title: text(r.title, 200, 'a title'),
++ task: {
++ source: oneOf(task.source, TASK_SOURCES, 'the task source'),
++ ref: text(task.ref, 200, 'a task reference'),
++ title: text(task.title, 200, 'a task title'),
++ ...(task.url ? { url: uri(task.url, 'the task URL') } : {}),
++ },
++ visibility: oneOf(r.visibility, VISIBILITIES, 'the visibility'),
++ ...(r.allowSites === undefined ? {} : {
++ allow_sites: (() => {
++ if (!Array.isArray(r.allowSites) || r.allowSites.length > 50) throw new TypeError('streams: allowSites is an array of at most 50 URIs');
++ return r.allowSites.map((site) => uri(site, 'an allowed site'));
++ })(),
++ }),
++ };
++ return call({ method: 'POST', path: '/api/streams/agent', json: body, signal });
++ },
++
++ /**
++ * A new publish ticket for a stream that is still live → the publish part again (the desk renews at three hours).
++ * The old ticket stops working at once.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async renewTicket(id, { signal } = {}) {
++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/ticket`, signal });
++ },
++
++ /**
++ * Revoke the publish ticket: the kill switch. Live ends the WHIP session at once.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async revokeTicket(id, { signal } = {}) {
++ return call({ method: 'DELETE', path: `/api/streams/agent/${streamId(id)}/ticket`, signal });
++ },
++
++ /**
++ * What the desk polls every ten seconds → { viewers, encode, curtain, ends_at }. While encode is false the
++ * desk stops encoding and sends a snapshot every five seconds (postFrame).
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ ticket?: string, signal?: AbortSignal }} [options] the publish ticket, when the desk has one
++ */
++ async state(id, { ticket, signal } = {}) {
++ return call({ method: 'GET', path: `/api/streams/agent/${streamId(id)}/state`, ...(ticket ? { token: ticket } : {}), signal });
++ },
++
++ /**
++ * Raise or drop the desk's privacy screen. While it is up Live pauses every viewer's consumer, so a desk that
++ * failed to blank still shows nothing; viewers see "Private screen" and never why.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {boolean} up true to raise it
++ * @param {'password_field'|'secret_file'|'env_print'|'manual'|'site'|'detector'} reason why
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async setCurtain(id, up, reason, { signal } = {}) {
++ const body = { up: Boolean(up), reason: oneOf(reason, CURTAIN_REASONS, 'the curtain reason') };
++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/curtain`, json: body, signal });
++ },
++
++ /**
++ * Send the last frame (JPEG or WebP, at most 200 KB): the card's picture and the task page's. The desk sends it
++ * while Live wants no video.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {Uint8Array|ArrayBuffer} bytes the frame
++ * @param {{ ticket?: string, contentType?: 'image/jpeg'|'image/webp', signal?: AbortSignal }} [options]
++ */
++ async postFrame(id, bytes, { ticket, contentType = 'image/jpeg', signal } = {}) {
++ const body = frameBytes(bytes);
++ if (!body.byteLength) throw new TypeError('streams: a frame is not empty');
++ if (body.byteLength > MAX_FRAME_BYTES) throw new TypeError('streams: a frame is at most 200 KB');
++ const res = await client.request({
++ ...at, method: 'PUT', path: `/api/streams/agent/${streamId(id)}/frame`, body,
++ headers: { 'Content-Type': oneOf(contentType, FRAME_TYPES, 'the frame type') },
++ ...(ticket ? { token: ticket } : {}), idempotencyKey: false, signal,
++ });
++ return res.data;
++ },
++
++ /**
++ * Switch who may watch (the owner's switch): private, unlisted or public.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {'private'|'unlisted'|'public'} visibility
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async setVisibility(id, visibility, { signal } = {}) {
++ const body = { visibility: oneOf(visibility, VISIBILITIES, 'the visibility') };
++ return call({ method: 'PUT', path: `/api/streams/agent/${streamId(id)}/visibility`, json: body, signal });
++ },
++
++ /**
++ * End the stream. The card stays, with the last frame, the worked seconds and the VOD once it is recorded.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async end(id, { signal } = {}) {
++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/end`, signal });
++ },
++
++ /**
++ * A view ticket (wvt_…, ten minutes) for the player's join message. The owner's own session gets one, and so
++ * does anyone the sharing system lets see a private stream.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async viewTicket(id, { signal } = {}) {
++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/view-ticket`, signal });
++ },
++ };
++}
++
++module.exports = { createStreamsClient };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex f811e1f..4826e7a 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex a36ceeb..3a99977 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'src/streams.js', 'esm/streams.mjs', 'types/streams.d.ts', 'docs/streams.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 83); // 0.89.0 adds openvibe-sdk/exchange and H1 adds openvibe-sdk/assertions
++ assert.equal(subpaths.length, 84); // 0.90.0 adds openvibe-sdk/assertions and 0.91.0 openvibe-sdk/streams
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/streams.test.js b/test/streams.test.js
nnew file mode 100644
iindex 0000000..89c623e
---- /dev/null
++++ b/test/streams.test.js
@@@ -0,0 +1,128 @@
++'use strict';
++// openvibe-sdk/streams: the Agent Streams plan (S1) — an agent's own stream on Live. The routes, the bodies, the
++// publish ticket as a bearer of its own on the desk's routes, the frame upload, and the guards the module sets before
++// anything leaves the process.
++const assert = require('assert');
++const { createClient } = require('../src/core');
++const { createStreamsClient } = require('../src/streams');
++const { stubServer, send, run } = require('./helpers');
++
++const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
++
++(async () => {
++ const server = await stubServer(async (req, res, body) => {
++ const path = req.url.split('?')[0];
++ if (req.method === 'POST' && path === '/api/streams/agent') {
++ return send(res, 201, {
++ stream: { id: AGS, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', label: 'AI agent', state: 'idle' },
++ publish: { whip_url: `https://whip.openvibe.live/whip/agent/${AGS}`, ticket: TICKET, expires_at: '2026-11-06T16:00:00.000Z' },
++ });
++ }
++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/ticket`) {
++ return send(res, 201, { whip_url: `https://whip.openvibe.live/whip/agent/${AGS}`, ticket: TICKET, expires_at: '2026-11-06T20:00:00.000Z' });
++ }
++ if (req.method === 'DELETE' && path === `/api/streams/agent/${AGS}/ticket`) return send(res, 200, { revoked: true });
++ if (req.method === 'GET' && path === `/api/streams/agent/${AGS}/state`) {
++ return send(res, 200, { viewers: 2, encode: false, curtain: { up: false }, ends_at: null });
++ }
++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/curtain`) return send(res, 200, { ok: true });
++ if (req.method === 'PUT' && path === `/api/streams/agent/${AGS}/frame`) return send(res, 200, { ok: true });
++ if (req.method === 'PUT' && path === `/api/streams/agent/${AGS}/visibility`) return send(res, 200, { ok: true });
++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/end`) return send(res, 200, { ok: true });
++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/view-ticket`) {
++ return send(res, 201, { ticket: 'wvt_0123456789abcdefghijklmnop', expires_at: '2026-11-06T12:10:00.000Z' });
++ }
++ return send(res, 404, { error: 'not found', code: 'live.not_found' });
++ });
++ const client = createClient({ baseUrls: { live: server.url }, token: 'person-token', retries: 0 });
++ const streams = createStreamsClient(client);
++ const last = () => server.requests[server.requests.length - 1];
++
++ await run([
++ ['startAgentStream posts the create request and answers the stream and the publish part', async () => {
++ const r = await streams.startAgentStream({
++ title: 'Fixing the login page',
++ task: { source: 'codes', ref: 'task_01JAB', title: 'Fix the login page', url: 'https://codes.openvibe.live/tasks/task_01JAB' },
++ visibility: 'private',
++ allowSites: ['https://example.com'],
++ });
++ assert.strictEqual(last().method, 'POST');
++ assert.strictEqual(last().url, '/api/streams/agent');
++ assert.deepStrictEqual(JSON.parse(String(last().body)), {
++ title: 'Fixing the login page',
++ task: { source: 'codes', ref: 'task_01JAB', title: 'Fix the login page', url: 'https://codes.openvibe.live/tasks/task_01JAB' },
++ visibility: 'private',
++ allow_sites: ['https://example.com'],
++ });
++ assert.strictEqual(last().headers.authorization, 'Bearer person-token', "the person's token rides along");
++ assert.strictEqual(r.stream.id, AGS);
++ assert.strictEqual(r.publish.ticket, TICKET);
++ }],
++
++ ['the guards run before anything leaves', async () => {
++ const before = server.requests.length;
++ await assert.rejects(() => streams.startAgentStream({ title: '', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private' }), /title/);
++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'browser', ref: 'a', title: 'b' }, visibility: 'private' }), /task source/);
++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'everyone' }), /visibility/);
++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b', url: '/relative/path' }, visibility: 'private' }), /task URL/);
++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private', allowSites: 'https://example.com' }), /allowSites/);
++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private', allowSites: ['not a URI'] }), /allowed site/);
++ await assert.rejects(() => streams.state('stream_77'), /agent stream id/);
++ await assert.rejects(() => streams.setCurtain(AGS, true, 'gossip'), /curtain reason/);
++ await assert.rejects(() => streams.setVisibility(AGS, 'everyone'), /visibility/);
++ await assert.rejects(() => streams.postFrame(AGS, new Uint8Array(200 * 1024 + 1)), /200 KB/);
++ assert.strictEqual(server.requests.length, before, 'nothing was sent');
++ }],
++
++ ['the ticket routes: renew, revoke', async () => {
++ await streams.renewTicket(AGS);
++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/ticket`]);
++ await streams.revokeTicket(AGS);
++ assert.deepStrictEqual([last().method, last().url], ['DELETE', `/api/streams/agent/${AGS}/ticket`]);
++ }],
++
++ ["the desk's routes carry the publish ticket, not the person's token", async () => {
++ await streams.state(AGS, { ticket: TICKET });
++ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);
++ await streams.state(AGS);
++ assert.strictEqual(last().headers.authorization, 'Bearer person-token', 'without a ticket the person\'s token is used');
++ }],
++
++ ['the state poll answers viewers, encode, curtain and ends_at', async () => {
++ const s = await streams.state(AGS, { ticket: TICKET });
++ assert.deepStrictEqual(s, { viewers: 2, encode: false, curtain: { up: false }, ends_at: null });
++ }],
++
++ ['the curtain goes up and comes down', async () => {
++ await streams.setCurtain(AGS, true, 'password_field');
++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/curtain`]);
++ assert.deepStrictEqual(JSON.parse(String(last().body)), { up: true, reason: 'password_field' });
++ }],
++
++ ['a frame goes up as bytes with its own type', async () => {
++ const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
++ await streams.postFrame(AGS, jpeg, { ticket: TICKET });
++ assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/frame`]);
++ assert.strictEqual(last().headers['content-type'], 'image/jpeg');
++ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);
++ assert.deepStrictEqual([...last().body], [...jpeg]);
++ await streams.postFrame(AGS, jpeg, { contentType: 'image/webp' });
++ assert.strictEqual(last().headers['content-type'], 'image/webp');
++ }],
++
++ ['visibility, end and the view ticket', async () => {
++ await streams.setVisibility(AGS, 'unlisted');
++ assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/visibility`]);
++ assert.deepStrictEqual(JSON.parse(String(last().body)), { visibility: 'unlisted' });
++ await streams.end(AGS);
++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/end`]);
++ const v = await streams.viewTicket(AGS);
++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/view-ticket`]);
++ assert.ok(v.ticket.startsWith('wvt_'));
++ }],
++ ]);
++
++ await server.close();
++ console.log('openvibe-sdk/streams: the agent-stream routes, the ticket as a bearer, the frame upload and the guards');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex 8aa15d5..c78a950 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -74,7 +74,7 @@ export type ServiceTokenClaims = {
*/
env?: "sandbox" | "production";
/**
-- * The person who authorized an app through the authorization-code flow, or the owner an agent acts for (always present on agent tokens). Absent on client_credentials tokens.
++ * The person who authorized an app through the authorization-code flow, or the owner an agent acts for (always present on agent tokens). Also present on a node token that asks for an audience where the machine acts for its owner (Agent Streams S1: openvibe.live and live.agent_stream.publish); absent on every other client_credentials token.
*/
on_behalf_of?: string;
/**
ddiff --git a/types/streams.d.ts b/types/streams.d.ts
nnew file mode 100644
iindex 0000000..776df6e
---- /dev/null
++++ b/types/streams.d.ts
@@@ -0,0 +1,69 @@
++/** openvibe-sdk/streams: the Agent Streams plan (S1) — an agent's own stream on OpenVibe.Live. */
++type Opts = { signal?: AbortSignal };
++/** Who may watch an agent stream. */
++export type AgentStreamVisibility = 'private' | 'unlisted' | 'public';
++/** Where the agent's task runs. */
++export type AgentTaskSource = 'codes' | 'actor' | 'desk' | 'node';
++/** Why the desk's privacy screen is up. */
++export type CurtainReason = 'password_field' | 'secret_file' | 'env_print' | 'manual' | 'site' | 'detector';
++export interface AgentTask {
++ source: AgentTaskSource;
++ ref: string;
++ title: string;
++ url?: string;
++}
++export interface AgentStream {
++ id: string;
++ owner: string;
++ channel: string;
++ title: string;
++ task: AgentTask;
++ visibility: AgentStreamVisibility;
++ mode: 'watch' | 'narrated' | 'copilot' | 'chatplays';
++ state: 'idle' | 'live' | 'ended';
++ curtain: { up: boolean; since?: string | null };
++ viewers: number;
++ started_at: string | null;
++ ended_at: string | null;
++ worked_seconds: number;
++ last_frame_url: string | null;
++ vod_id: string | null;
++ page_url: string;
++ label: 'AI agent';
++}
++/** The publish part: where to publish, with the ticket that opens the door (shown once). */
++export interface AgentStreamPublish { whip_url: string; ticket: string; expires_at: string }
++/** What the desk polls: viewers, whether Live wants video, the privacy screen, when it ends. */
++export interface AgentStreamState {
++ viewers: number;
++ encode: boolean;
++ curtain: { up: boolean; since?: string | null; reason?: CurtainReason };
++ ends_at: string | null;
++}
++export interface StartAgentStreamRequest {
++ title: string;
++ task: AgentTask;
++ visibility: AgentStreamVisibility;
++ allowSites?: string[];
++}
++export interface StreamsClient {
++ /** Start an agent's stream → { stream, publish }. */
++ startAgentStream(request: StartAgentStreamRequest, opts?: Opts): Promise<{ stream: AgentStream; publish: AgentStreamPublish }>;
++ /** A new publish ticket for a stream that is still live. */
++ renewTicket(id: string, opts?: Opts): Promise<AgentStreamPublish>;
++ /** Revoke the publish ticket: the kill switch. */
++ revokeTicket(id: string, opts?: Opts): Promise<Record<string, unknown>>;
++ /** What the desk polls every ten seconds (with the publish ticket). */
++ state(id: string, opts?: Opts & { ticket?: string }): Promise<AgentStreamState>;
++ /** Raise or drop the desk's privacy screen. */
++ setCurtain(id: string, up: boolean, reason: CurtainReason, opts?: Opts): Promise<Record<string, unknown>>;
++ /** Send the last frame (JPEG or WebP, at most 200 KB). */
++ postFrame(id: string, bytes: Uint8Array | ArrayBuffer, opts?: Opts & { ticket?: string; contentType?: 'image/jpeg' | 'image/webp' }): Promise<Record<string, unknown>>;
++ /** Switch who may watch. */
++ setVisibility(id: string, visibility: AgentStreamVisibility, opts?: Opts): Promise<Record<string, unknown>>;
++ /** End the stream; the card stays. */
++ end(id: string, opts?: Opts): Promise<Record<string, unknown>>;
++ /** A view ticket for the player's join message (ten minutes). */
++ viewTicket(id: string, opts?: Opts): Promise<{ ticket: string; expires_at: string }>;
++}
++export function createStreamsClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any>; origin(service: string): Promise<string> }, options?: { baseUrl?: string }): StreamsClient;