openvibers/openvibe.contracts Commits

Contracts 0.410.0: release train for #455, #456 and #457 (#458)

OP OpenVibers committed 1 days ago · 5ca952b20b · 123 files, +12142 −105

* Layouts stage 1: surface, layout, suggestion and change contracts (0.410.0)

* Elements stage 1: ui.element, ui.trigger, ui.action, ui.element-state and the two Live events (0.411.0)

* ui.layout: a docked part keeps its size in cells

* ui.layout: the stored active flag is part of the document

* Elements: robot.head_preset names up/center/down; headlight and say match Bot's own kinds

* Elements: the state event describes the channel facts it carries

* Deep dive: ai.deep-dive@1 and ai.deep_dive.updated@1 (0.412.0)

* Merge branch 'pr456' into release/contracts-410

# Conflicts:
#	CHANGELOG.md
#	README.md
#	contracts/catalog.json
#	generated/json-schema/bundle.json
#	generated/openapi/actor.json
#	generated/openapi/ai.json
#	generated/openapi/bench.json
#	generated/openapi/billing.json
#	generated/openapi/blog.json
#	generated/openapi/bot.json
#	generated/openapi/chat.json
#	generated/openapi/codes.json
#	generated/openapi/community.json
#	generated/openapi/connect.json
#	generated/openapi/coupons.json
#	generated/openapi/deals.json
#	generated/openapi/design.json
#	generated/openapi/domains.json
#	generated/openapi/events.json
#	generated/openapi/exchange.json
#	generated/openapi/food.json
#	generated/openapi/games.json
#	generated/openapi/git.json
#	generated/openapi/host.json
#	generated/openapi/inventory.json
#	generated/openapi/live.json
#	generated/openapi/mail.json
#	generated/openapi/media-hub.json
#	generated/openapi/media.json
#	generated/openapi/network.json
#	generated/openapi/news.json
#	generated/openapi/openre.json
#	generated/openapi/play.json
#	generated/openapi/quest.json
#	generated/openapi/report.json
#	generated/openapi/reviews.json
#	generated/openapi/run.json
#	generated/openapi/search.json
#	generated/openapi/services.json
#	generated/openapi/sources.json
#	generated/openapi/space.json
#	generated/openapi/tips.json
#	generated/openapi/today.json
#	generated/openapi/tools.json
#	generated/openapi/trade.json
#	generated/openapi/vip.json
#	generated/openapi/vision.json
#	generated/openapi/watch.json
#	generated/openapi/wiki.json
#	generated/openapi/work.json
#	generated/openapi/zone.json
#	generated/typescript/index.d.ts
#	package.json

* Merge branch 'pr457' into release/contracts-410

# Conflicts:
#	CHANGELOG.md
#	contracts/catalog.json
#	generated/json-schema/bundle.json
#	generated/openapi/actor.json
#	generated/openapi/ai.json
#	generated/openapi/bench.json
#	generated/openapi/billing.json
#	generated/openapi/blog.json
#	generated/openapi/bot.json
#	generated/openapi/chat.json
#	generated/openapi/codes.json
#	generated/openapi/community.json
#	generated/openapi/connect.json
#	generated/openapi/coupons.json
#	generated/openapi/deals.json
#	generated/openapi/design.json
#	generated/openapi/domains.json
#	generated/openapi/events.json
#	generated/openapi/exchange.json
#	generated/openapi/food.json
#	generated/openapi/games.json
#	generated/openapi/git.json
#	generated/openapi/host.json
#	generated/openapi/inventory.json
#	generated/openapi/live.json
#	generated/openapi/mail.json
#	generated/openapi/media-hub.json
#	generated/openapi/media.json
#	generated/openapi/network.json
#	generated/openapi/news.json
#	generated/openapi/openre.json
#	generated/openapi/play.json
#	generated/openapi/quest.json
#	generated/openapi/report.json
#	generated/openapi/reviews.json
#	generated/openapi/run.json
#	generated/openapi/search.json
#	generated/openapi/services.json
#	generated/openapi/sources.json
#	generated/openapi/space.json
#	generated/openapi/tips.json
#	generated/openapi/today.json
#	generated/openapi/tools.json
#	generated/openapi/trade.json
#	generated/openapi/vip.json
#	generated/openapi/vision.json
#	generated/openapi/watch.json
#	generated/openapi/wiki.json
#	generated/openapi/work.json
#	generated/openapi/zone.json
#	generated/typescript/index.d.ts
#	package.json

* Contracts 0.410.0: release train for #455, #456 and #457

The layouts of make-your-own-controls (#455), Elements stage 1 (#456) and the
deep dive (#457) in one release. Suite 31041 checks; no breaking changes against
0.409.0.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 62628dc5..b6efec8a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,43 @@
# Changelog
++## 0.410.0 — 2026-11-11
++
++A train of three pull requests: the layouts of make-your-own-controls (#455), Elements stage 1 (#456) and the deep dive
++(#457).
++
++- Add `ui.surface@1` (the catalog of a surface's parts, their minimum sizes, styles, bindings and
++ `never_covers` rules), `ui.layout@1` (a person's arrangement on one device class, with its name, author and
++ sharing), `ui.layout-request@1` (catalog, current layout, words and device for the AI task) and
++ `ui.layout-suggestion@1` (a validated layout with a one-sentence summary, reasons and the model's receipt, or
++ an honest refusal with the validator's reasons).
++- Add `network.layout.changed@1`: the event Network writes in the transaction that sets, saves, renames, deletes,
++ shares, publishes, copies or resets one person's layout. It carries the surface, device, layout id, change and
++ revision, never account data or layout contents.
++- Add `ai.layout.suggest` (first-party, `POST /api/v1/ui/layout/suggest`) and `network.layout.read` (first-party,
++ `GET /api/v1/people/:subject/layouts/:surface`), and list them in the AI and Network manifests.
++- The fixtures cover a bot-controls catalog, a phone layout, a docked overlay, a suggestion and a refusal, a
++ request and the event; the suite checks part ids, theme tokens, never_covers targets and the 44 px minimum. (#455)
++- Add `ui.element@1`: a look from OpenVibe.Design's components and the theme's tokens, a recipe (triggers, conditions,
++ actions), the capabilities the element declares, an optional mod reference (id, version, digest) and its sharing.
++ The store assigns the id and revision; a draft has neither.
++- Add `ui.trigger@1` (press, hold, double-press, key, gamepad, chat command and text, follow, tip, AI Moment, Rundown
++ topic, timer, schedule, robot telemetry and event, stream start and end, viewer count, another element's event) and
++ `ui.action@1` (robot, chat, screen, stream, AI, Actor, webhook and tip-goal actions), each action carrying the product
++ that authorises it. `ui.action@1`'s `$defs.capability` is the one list an element's `capabilities` draws from.
++- Add `ui.element-state@1`: the element's own store (counters, scores, lists), with a revision that grows per accepted
++ change and values that are booleans, numbers, short strings or lists of them, never objects.
++- Add `live.element.pressed@1` and `live.element.state@1`: a press with the presser's public name and the trigger, and
++ the store with its revision, written by Live.
++- Fixtures cover a gallery dance button, a chat counter, a mod element, trigger and action variants, the state, and
++ both events; `test/elements.test.js` holds the capability list to the action catalog, each kind to its product, and
++ the recipe to the element's declared capabilities. (#456)
++- Add `ai.deep-dive@1`: a deep dive run's report as OpenVibe.AI's `design.deep_dive` workflow produces it for
++ OpenVibe.Exchange and OpenVibe.Design — the question and depth, the seven steps, every source, every capture (a
++ private Media object per viewport and theme, with its DOM facts and Expert extras), the per-site analysis, the
++ synthesis, the recommendations checked against OpenVibe's rules, and the receipt. Every claim carries its evidence.
++- Add `ai.deep_dive.updated@1`, the event AI writes on each step transition and at the end, so a page can follow a
++ run without polling and Design can attach a finished report. (#457)
++
## 0.409.0 — 2026-11-11
- Define machine access requests, grants and signed token claims; the agent directory, A2A tasks and results; five Network events and four registration and relay capabilities.
ddiff --git a/README.md b/README.md
iindex 3b66d18a..1bf61ae6 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.services/llms.txt
**Install and test:** `npm ci && npm test` (Node 20 or newer)
--**Status:** alpha (first released in Wave 1; the release is package.json's `version`, tagged). At v0.408.0: 1238 schemas in `contracts/catalog.json`, 66 service manifests, 516 capability manifests, 14 namespace manifests and the staff capability map. Service repositories run `openvibe-contracts-check` and the pin-drift check in CI, and deployed services verify service and app tokens with `serviceAuth` in production. Consumers pin release tags (each repository's `STATUS.json` names its pin). `node scripts/docs-currency.js <root>` checks every checkout's README and `STATUS.json` against its code: the pins and versions they name, a library's consumer list, the deployed flag against the manifest, README links, and the paths and migrations they name. `node scripts/estate.js` generates `docs/ESTATE.md` and `manifests/repositories/` from the estate checkouts, and `--check` fails on drift (plan T1 step 3).
++**Status:** alpha (first released in Wave 1; the release is package.json's `version`, tagged). At v0.410.0: 1261 schemas in `contracts/catalog.json`, 67 service manifests, 523 capability manifests, 14 namespace manifests and the staff capability map. Service repositories run `openvibe-contracts-check` and the pin-drift check in CI, and deployed services verify service and app tokens with `serviceAuth` in production. Consumers pin release tags (each repository's `STATUS.json` names its pin). `node scripts/docs-currency.js <root>` checks every checkout's README and `STATUS.json` against its code: the pins and versions they name, a library's consumer list, the deployed flag against the manifest, README links, and the paths and migrations they name. `node scripts/estate.js` generates `docs/ESTATE.md` and `manifests/repositories/` from the estate checkouts, and `--check` fails on drift (plan T1 step 3).
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
ddiff --git a/contracts/ai/deep-dive.v1.json b/contracts/ai/deep-dive.v1.json
nnew file mode 100644
iindex 00000000..5ba94f64
---- /dev/null
++++ b/contracts/ai/deep-dive.v1.json
@@@ -0,0 +1,925 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ai/deep-dive.v1.json",
++ "title": "AiDeepDive",
++ "description": "A deep dive: expert design research run by OpenVibe.AI (workflow design.deep_dive) for OpenVibe.Exchange's /deep-dive page and, through the API, OpenVibe.Design. One document holds the question and depth (the input), the seven steps, every source read, every capture (a private Media object per viewport and theme, with its DOM facts and Expert extras), the per-site analysis, the synthesis across sites, the recommendations checked against OpenVibe's own rules, and the receipt of every model call and capture. Every claim carries its evidence: the site, the capture or crop, the URL and the date it was read. Nothing is copied: captures are private to the run's owner and the report shows them as attributed commentary.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "schema",
++ "run_id",
++ "status",
++ "input",
++ "steps",
++ "sources",
++ "captures",
++ "analysis",
++ "synthesis",
++ "recommendations",
++ "receipt",
++ "gaps",
++ "created_at",
++ "updated_at"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The run id; a deep dive is a run (GET /api/v1/deep-dives/{id})."
++ },
++ "schema": {
++ "const": "ai.deep-dive@1"
++ },
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "enum": [
++ "queued",
++ "running",
++ "choosing",
++ "succeeded",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "question",
++ "depth",
++ "max_sites"
++ ],
++ "properties": {
++ "question": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500
++ },
++ "depth": {
++ "enum": [
++ "quick",
++ "thorough",
++ "expert"
++ ]
++ },
++ "max_sites": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 30
++ },
++ "sites": {
++ "type": "array",
++ "maxItems": 30,
++ "items": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "description": "Sites the person named when starting the run."
++ }
++ }
++ },
++ "steps": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "key",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "enum": [
++ "plan",
++ "search",
++ "choose",
++ "capture",
++ "analyse",
++ "synthesise",
++ "report"
++ ]
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "running",
++ "done",
++ "skipped",
++ "failed"
++ ]
++ },
++ "detail": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "sources": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "kind",
++ "title",
++ "url",
++ "read_at"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "kind": {
++ "enum": [
++ "web",
++ "picture",
++ "icon",
++ "project",
++ "rules",
++ "component",
++ "named"
++ ]
++ },
++ "title": {
++ "type": "string",
++ "maxLength": 400
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "publisher": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200
++ },
++ "engine": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 80
++ },
++ "snippet": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "license": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 120
++ },
++ "read_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "captures": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "source_id",
++ "url",
++ "captured_at",
++ "viewport",
++ "theme",
++ "full_page",
++ "crops"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^cap_[A-Za-z0-9_-]{1,40}$"
++ },
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "final_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 400
++ },
++ "captured_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "viewport": {
++ "enum": [
++ 390,
++ 1280
++ ]
++ },
++ "theme": {
++ "enum": [
++ "light",
++ "dark"
++ ]
++ },
++ "full_page": {
++ "type": "boolean"
++ },
++ "media_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "width": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1
++ },
++ "height": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1
++ },
++ "crops": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "media_id",
++ "y",
++ "height"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^crop_[A-Za-z0-9_-]{1,40}$"
++ },
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "y": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "height": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++ }
++ },
++ "dom": {
++ "type": [
++ "object",
++ "null"
++ ]
++ },
++ "extras": {
++ "type": [
++ "object",
++ "null"
++ ]
++ },
++ "skip": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "Why the capture did not happen: robots_disallowed, not_public, no_chrome, timeout, …"
++ },
++ "cached": {
++ "type": "boolean"
++ }
++ }
++ }
++ },
++ "analysis": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "url",
++ "summary",
++ "strengths",
++ "weaknesses",
++ "evidence"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "summary": {
++ "type": "string",
++ "maxLength": 4000
++ },
++ "first_impression": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "hierarchy": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "density": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "type": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "colour": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "motion": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "words": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "strengths": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "weaknesses": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "accessibility": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "speed": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "synthesis": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "patterns",
++ "comparison",
++ "best",
++ "mistakes",
++ "gap",
++ "reference_tokens",
++ "do",
++ "dont",
++ "accessibility",
++ "speed"
++ ],
++ "properties": {
++ "patterns": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name",
++ "what",
++ "sites",
++ "evidence"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "maxLength": 160
++ },
++ "what": {
++ "type": "string",
++ "maxLength": 1200
++ },
++ "sites": {
++ "type": "array",
++ "maxItems": 30,
++ "items": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ }
++ },
++ "usage_count": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 30
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "comparison": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "columns",
++ "rows"
++ ],
++ "properties": {
++ "columns": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 120
++ }
++ },
++ "rows": {
++ "type": "array",
++ "items": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 1000
++ }
++ }
++ }
++ }
++ },
++ "best": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "what",
++ "evidence"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "what": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ },
++ "mistakes": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "what",
++ "evidence"
++ ],
++ "properties": {
++ "what": {
++ "type": "string",
++ "maxLength": 1200
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "gap": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "reference_tokens": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "palettes",
++ "type_scales",
++ "spacing"
++ ],
++ "properties": {
++ "palettes": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "colours"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "colours": {
++ "type": "array",
++ "maxItems": 16,
++ "items": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++ }
++ },
++ "type_scales": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "families",
++ "sizes"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "families": {
++ "type": "array",
++ "maxItems": 8,
++ "items": {
++ "type": "string",
++ "maxLength": 120
++ }
++ },
++ "sizes": {
++ "type": "array",
++ "maxItems": 16,
++ "items": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++ }
++ },
++ "spacing": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "steps"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "steps": {
++ "type": "array",
++ "maxItems": 16,
++ "items": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "do": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "dont": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "accessibility": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "speed": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ }
++ }
++ },
++ "recommendations": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "what",
++ "why",
++ "rule",
++ "evidence"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^rec_[A-Za-z0-9_-]{1,40}$"
++ },
++ "what": {
++ "type": "string",
++ "maxLength": 1200
++ },
++ "why": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "rule": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "status",
++ "text"
++ ],
++ "properties": {
++ "id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 120
++ },
++ "status": {
++ "enum": [
++ "follows",
++ "would_change",
++ "none"
++ ]
++ },
++ "text": {
++ "type": "string",
++ "maxLength": 600
++ }
++ }
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "receipt": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "price_usd",
++ "held_usd",
++ "cost_usd",
++ "models",
++ "captures_count",
++ "synthetic"
++ ],
++ "properties": {
++ "price_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "held_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "captures_count": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "synthetic": {
++ "type": "boolean"
++ },
++ "models": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "step",
++ "provider",
++ "model",
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "ms"
++ ],
++ "properties": {
++ "step": {
++ "enum": [
++ "plan",
++ "choose",
++ "analyse",
++ "synthesise"
++ ]
++ },
++ "provider": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 80
++ },
++ "model": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200
++ },
++ "tokens_in": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "tokens_out": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "ms": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++ }
++ }
++ },
++ "gaps": {
++ "type": "array",
++ "maxItems": 40,
++ "items": {
++ "type": "string",
++ "maxLength": 400
++ }
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "evidence": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "claim",
++ "url",
++ "read_at"
++ ],
++ "properties": {
++ "claim": {
++ "type": "string",
++ "maxLength": 600
++ },
++ "source_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "capture_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^cap_[A-Za-z0-9_-]{1,40}$"
++ },
++ "crop_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^crop_[A-Za-z0-9_-]{1,40}$"
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "read_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 8f63f39d..fc42db37 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -11573,6 +11573,125 @@
"visibility": "first-party",
"compatibility": "backward",
"status": "active"
++ },
++ {
++ "id": "ui.surface",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/surface.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.layout",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/layout.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.layout-request",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/layout-request.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.layout-suggestion",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/layout-suggestion.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "network.layout.changed",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "events/payloads/network.layout.changed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.action",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/action.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.element",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/element.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.element-state",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/element-state.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ui.trigger",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "ui/trigger.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "live.element.pressed",
++ "version": "1.0.0",
++ "owner": "live",
++ "schema": "events/payloads/live.element.pressed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "live.element.state",
++ "version": "1.0.0",
++ "owner": "live",
++ "schema": "events/payloads/live.element.state.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
++ {
++ "id": "ai.deep-dive",
++ "version": "1.0.0",
++ "owner": "ai",
++ "schema": "ai/deep-dive.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-015"
++ },
++ {
++ "id": "ai.deep_dive.updated",
++ "version": "1.0.0",
++ "owner": "ai",
++ "schema": "events/payloads/ai.deep_dive.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-015"
}
]
}
ddiff --git a/contracts/events/payloads/ai.deep_dive.updated.v1.json b/contracts/events/payloads/ai.deep_dive.updated.v1.json
nnew file mode 100644
iindex 00000000..66e9eef0
---- /dev/null
++++ b/contracts/events/payloads/ai.deep_dive.updated.v1.json
@@@ -0,0 +1,147 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/ai.deep_dive.updated.v1.json",
++ "title": "AiDeepDiveUpdatedPayload",
++ "description": "ai.deep_dive.updated v1 (OpenVibe.AI server/deep-dive): a deep dive's step changed or it ended. One event per step transition and one at the end, so a page (OpenVibe.Exchange's /deep-dive) can follow a run without polling, and OpenVibe.Design can attach a finished report. The payload carries the run and deep-dive ids, the step, the progress and the status; never the question's answer, a capture or a prompt. Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low (important on failed), actor service:ai.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "deep_dive_id",
++ "run_id",
++ "status",
++ "step",
++ "progress",
++ "steps",
++ "depth",
++ "requester",
++ "on_behalf_of",
++ "url",
++ "error",
++ "occurred_at"
++ ],
++ "properties": {
++ "deep_dive_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "enum": [
++ "queued",
++ "running",
++ "choosing",
++ "succeeded",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "step": {
++ "enum": [
++ "plan",
++ "search",
++ "choose",
++ "capture",
++ "analyse",
++ "synthesise",
++ "report",
++ "done",
++ null
++ ]
++ },
++ "progress": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1
++ },
++ "steps": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "key",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "enum": [
++ "plan",
++ "search",
++ "choose",
++ "capture",
++ "analyse",
++ "synthesise",
++ "report"
++ ]
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "running",
++ "done",
++ "skipped",
++ "failed"
++ ]
++ }
++ }
++ }
++ },
++ "depth": {
++ "enum": [
++ "quick",
++ "thorough",
++ "expert"
++ ]
++ },
++ "requester": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://"
++ },
++ "error": {
++ "anyOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "code",
++ "detail"
++ ],
++ "properties": {
++ "code": {
++ "type": "string",
++ "maxLength": 80
++ },
++ "detail": {
++ "type": "string",
++ "maxLength": 500
++ }
++ }
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "occurred_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/live.element.pressed.v1.json b/contracts/events/payloads/live.element.pressed.v1.json
nnew file mode 100644
iindex 00000000..fe3e5232
---- /dev/null
++++ b/contracts/events/payloads/live.element.pressed.v1.json
@@@ -0,0 +1,29 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.element.pressed.v1.json",
++ "title": "LiveElementPressedPayload",
++ "description": "live.element.pressed v1 (OpenVibe.Live server/elements): a viewer's element press was accepted and ran. It carries the public facts the stream should see: the channel, the element's id and name, the presser's public name and subject, the trigger that fired, the channel points it cost and the element-state revision it left behind. It never carries the presser's email, address, account balance or anything private; a refused press does not produce it. Envelope: subject { type: element, id: el_… }, actor the presser's subject, visibility public, priority low. Also written to the channel's AI timeline as a moment, so the stream's own timeline can cite 'chat made the robot dance'.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["channel", "element_id", "element_name", "presser", "presser_name", "trigger", "at"],
++ "properties": {
++ "channel": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["username", "display_name"],
++ "properties": {
++ "username": { "type": "string", "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$" },
++ "display_name": { "type": "string", "minLength": 1, "maxLength": 80 }
++ },
++ "description": "The public channel facts (the channel page is public already)."
++ },
++ "element_id": { "type": "string", "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "element_name": { "type": "string", "minLength": 1, "maxLength": 80 },
++ "presser": { "$ref": "../../identity/subject-ref.v1.json" },
++ "presser_name": { "type": "string", "minLength": 1, "maxLength": 120, "description": "The display name the stream shows, never an email or an account id." },
++ "trigger": { "$ref": "../../ui/trigger.v1.json" },
++ "points_spent": { "type": "integer", "minimum": 0, "description": "Channel points the press cost; 0 when it was free." },
++ "state_revision": { "type": "integer", "minimum": 0, "description": "The element-state revision after the actions ran; 0 when the element has no state yet." },
++ "at": { "type": "string", "format": "date-time" }
++ }
++}
ddiff --git a/contracts/events/payloads/live.element.state.v1.json b/contracts/events/payloads/live.element.state.v1.json
nnew file mode 100644
iindex 00000000..05f2f5d1
---- /dev/null
++++ b/contracts/events/payloads/live.element.state.v1.json
@@@ -0,0 +1,51 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.element.state.v1.json",
++ "title": "LiveElementStatePayload",
++ "description": "live.element.state v1 (OpenVibe.Live server/elements): an element's own store changed, broadcast to everyone watching through Live's existing events path. Carries the channel's public facts and the same store as ui.element-state@1: the element instance, a revision that grows by one per accepted change, the values themselves and when. It is public state (a counter, a score, a wheel's entries), never the presser's data: a value may not be an object, a markup string or a URL, and the payload carries no account.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "channel",
++ "element_id",
++ "revision",
++ "values",
++ "updated_at"
++ ],
++ "properties": {
++ "channel": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "username",
++ "display_name"
++ ],
++ "properties": {
++ "username": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$"
++ },
++ "display_name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ }
++ },
++ "element_id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "values": {
++ "$ref": "../../ui/element-state.v1.json#/$defs/values"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/network.layout.changed.v1.json b/contracts/events/payloads/network.layout.changed.v1.json
nnew file mode 100644
iindex 00000000..3a7fb35d
---- /dev/null
++++ b/contracts/events/payloads/network.layout.changed.v1.json
@@@ -0,0 +1,23 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.layout.changed.v1.json",
++ "title": "NetworkLayoutChangedPayload",
++ "description": "network.layout.changed v1 (OpenVibe.Network server/layouts, written into network_event_outbox in the transaction that changes a stored layout). One event per change to one person's ui.layout@1: set active, saved, renamed, deleted, shared, unshared, published to the gallery, unpublished, copied from the gallery, or reset to the default. Envelope: subject { type: user, id } and the layout's revision, actor the person, visibility internal. The payload carries no account data and no layout contents, so an open page can announce a change and refetch with the person's own token.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["surface", "device", "layout_id", "change", "revision"],
++ "properties": {
++ "surface": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80
++ },
++ "device": { "enum": ["phone", "tablet", "computer"] },
++ "layout_id": { "type": "string", "pattern": "^lay_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "change": {
++ "enum": ["active", "saved", "renamed", "deleted", "shared", "unshared", "published", "unpublished", "copied", "reset"]
++ },
++ "revision": { "type": "integer", "minimum": 1 },
++ "sharing": { "enum": ["private", "link", "gallery"] }
++ }
++}
ddiff --git a/contracts/ui/action.v1.json b/contracts/ui/action.v1.json
nnew file mode 100644
iindex 00000000..898c9ef0
---- /dev/null
++++ b/contracts/ui/action.v1.json
@@@ -0,0 +1,702 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/action.v1.json",
++ "title": "UiAction",
++ "description": "ui.action@1: one action an Element's recipe may request, from the fixed catalog, with the product that authorises it. An element never has a power of its own: the host asks the product named here, and that product checks the presser's role, the element's declared capability, its cooldown and cost, and its own owner limits before it does anything. kind is also the capability id an element declares in ui.element@1, and product is fixed by kind: bot for robot.drive_pulse, robot.head_preset, robot.headlight, robot.say and robot.macro; chat for chat.message, chat.pin and chat.poll; live for the screen, stream and webhook actions; ai for ai.ask; actor for actor.task; billing for money.tip_goal. A robot action is a request through Bot's existing command path: turns, Stop, the e-stop and owner takeover are never bypassed. Money never moves from an action without Billing's own confirmation.",
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "direction",
++ "ms"
++ ],
++ "description": "Drive the robot for a moment, then stop. The duration is bounded by Bot's own max_command_ms and the owner's limits for the presser's role.",
++ "properties": {
++ "kind": {
++ "const": "robot.drive_pulse"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "direction": {
++ "enum": [
++ "forward",
++ "back",
++ "left",
++ "right",
++ "spin_left",
++ "spin_right"
++ ]
++ },
++ "ms": {
++ "type": "integer",
++ "minimum": 50,
++ "maximum": 3000
++ },
++ "speed": {
++ "type": "number",
++ "minimum": 0.1,
++ "maximum": 1,
++ "description": "Fraction of the robot's allowed speed; omitted means the owner's default for the role."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "preset"
++ ],
++ "description": "Take the robot's head to a preset. Bot maps up, center and down to the head actuator its robot profile declares; a robot without one refuses it.",
++ "properties": {
++ "kind": {
++ "const": "robot.head_preset"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "preset": {
++ "enum": [
++ "up",
++ "center",
++ "down"
++ ],
++ "description": "A head tilt preset. Bot still checks the robot profile and the owner's limits."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "on"
++ ],
++ "description": "Turn the robot's headlight on or off. Bot's own headlight kind is the authority: only the owner and People Operators may send it, and a robot whose profile has no headlight refuses it.",
++ "properties": {
++ "kind": {
++ "const": "robot.headlight"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "on": {
++ "type": "boolean"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "text"
++ ],
++ "description": "Speak a line through the robot. Text goes through the existing content checks and Bot's own speak limits.",
++ "properties": {
++ "kind": {
++ "const": "robot.say"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "macro"
++ ],
++ "description": "Replay a recorded robot macro through the safe command path, with its own deadline. Macros are stage 4 of Elements; a stage-1 product refuses this kind as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "robot.macro"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "macro": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "text"
++ ],
++ "description": "Send a chat message as the channel's bot. Text goes through Chat's content checks and rate limits.",
++ "properties": {
++ "kind": {
++ "const": "chat.message"
++ },
++ "product": {
++ "const": "chat"
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 400
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "source"
++ ],
++ "properties": {
++ "kind": {
++ "const": "chat.pin"
++ },
++ "product": {
++ "const": "chat"
++ },
++ "source": {
++ "enum": [
++ "trigger",
++ "latest"
++ ],
++ "description": "trigger pins the message that caused this action; latest pins the newest chat message."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "question",
++ "options"
++ ],
++ "properties": {
++ "kind": {
++ "const": "chat.poll"
++ },
++ "product": {
++ "const": "chat"
++ },
++ "question": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "options": {
++ "type": "array",
++ "minItems": 2,
++ "maxItems": 10,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "title"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.alert"
++ },
++ "product": {
++ "const": "live"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "name"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.animation"
++ },
++ "product": {
++ "const": "live"
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "An animation the page or overlay has; an unknown name is refused, never fetched."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.confetti"
++ },
++ "product": {
++ "const": "live"
++ },
++ "intensity": {
++ "enum": [
++ "low",
++ "medium",
++ "high"
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "sound"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.sound"
++ },
++ "product": {
++ "const": "live"
++ },
++ "sound": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "A sound from the streamer's own sound list; Live never fetches a URL for it."
++ },
++ "volume": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "text",
++ "voice"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.tts"
++ },
++ "product": {
++ "const": "live"
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300
++ },
++ "voice": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "A voice from the streamer's own voice list."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "key",
++ "by"
++ ],
++ "description": "Move one of the element's counters. The host applies it to the element's own state and Live broadcasts the new state.",
++ "properties": {
++ "kind": {
++ "const": "screen.counter"
++ },
++ "product": {
++ "const": "live"
++ },
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{0,31}$"
++ },
++ "by": {
++ "type": "integer",
++ "minimum": -1000,
++ "maximum": 1000
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "key",
++ "value"
++ ],
++ "description": "Set one of the element's state values: a boolean, a number, a short string or a list of those (a wheel's entries, a scoreboard). Objects are not state values.",
++ "properties": {
++ "kind": {
++ "const": "screen.state"
++ },
++ "product": {
++ "const": "live"
++ },
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{0,31}$"
++ },
++ "value": {
++ "oneOf": [
++ {
++ "type": "boolean"
++ },
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ },
++ {
++ "type": "array",
++ "maxItems": 64,
++ "items": {
++ "oneOf": [
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ }
++ ]
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "element",
++ "visible"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.visibility"
++ },
++ "product": {
++ "const": "live"
++ },
++ "element": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "visible": {
++ "type": "boolean"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "scene"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.scene"
++ },
++ "product": {
++ "const": "live"
++ },
++ "scene": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "A scene the layout has; an unknown scene is refused."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.clip"
++ },
++ "product": {
++ "const": "live"
++ },
++ "seconds": {
++ "type": "integer",
++ "minimum": 5,
++ "maximum": 30,
++ "description": "How far back the clip starts, at most 30 seconds."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.moment"
++ },
++ "product": {
++ "const": "live"
++ },
++ "label": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 120
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "title"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.title"
++ },
++ "product": {
++ "const": "live"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 140
++ }
++ },
++ "description": "Change the stream title. Live allows it only for the channel's owner, whatever the element's trigger says."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "category"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.category"
++ },
++ "product": {
++ "const": "live"
++ },
++ "category": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ },
++ "description": "Change the stream category. Live allows it only for the channel's owner."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "prompt"
++ ],
++ "description": "Ask an AI model through Exchange's catalogue, with the element's budget and a receipt. Stage 2 of Elements; a stage-1 product refuses it as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "ai.ask"
++ },
++ "product": {
++ "const": "ai"
++ },
++ "prompt": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500
++ },
++ "model": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "max_cost_usd": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "task"
++ ],
++ "description": "Hand a task to an Actor agent. Stage 2 of Elements; a stage-1 product refuses it as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "actor.task"
++ },
++ "product": {
++ "const": "actor"
++ },
++ "task": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 8000
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "endpoint"
++ ],
++ "description": "Call one of the streamer's own webhook endpoints. The element names the endpoint the streamer listed; it never carries a URL, and the payload never carries a viewer's data. Stage 2 of Elements; a stage-1 product refuses it as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "webhook.call"
++ },
++ "product": {
++ "const": "live"
++ },
++ "endpoint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]{0,31}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "goal"
++ ],
++ "description": "Update a tip goal's display. Money never moves here; Billing's own sheet confirms any charge. Stage 4 of Elements.",
++ "properties": {
++ "kind": {
++ "const": "money.tip_goal"
++ },
++ "product": {
++ "const": "billing"
++ },
++ "goal": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40
++ }
++ }
++ }
++ ],
++ "$defs": {
++ "capability": {
++ "description": "The action kinds an Element may declare in ui.element@1 capabilities. One id, one product: the host routes an action only to the product that owns it.",
++ "enum": [
++ "robot.drive_pulse",
++ "robot.head_preset",
++ "robot.headlight",
++ "robot.say",
++ "robot.macro",
++ "chat.message",
++ "chat.pin",
++ "chat.poll",
++ "screen.alert",
++ "screen.animation",
++ "screen.confetti",
++ "screen.sound",
++ "screen.tts",
++ "screen.counter",
++ "screen.state",
++ "screen.visibility",
++ "screen.scene",
++ "stream.clip",
++ "stream.moment",
++ "stream.title",
++ "stream.category",
++ "ai.ask",
++ "actor.task",
++ "webhook.call",
++ "money.tip_goal"
++ ]
++ }
++ }
++}
ddiff --git a/contracts/ui/element-state.v1.json b/contracts/ui/element-state.v1.json
nnew file mode 100644
iindex 00000000..c87866d8
---- /dev/null
++++ b/contracts/ui/element-state.v1.json
@@@ -0,0 +1,52 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/element-state.v1.json",
++ "title": "UiElementState",
++ "description": "ui.element-state@1: the small store an Element keeps for itself (counters, scores, lists, a wheel's entries), as Live broadcasts it to everyone watching and as a read returns it. CHANNEL and ELEMENT_ID name the element instance, REVISION grows by one with each accepted change so a late frame never overwrites a newer one, and VALUES is the store itself: at most 32 keys, each a boolean, a number, a short string or a list of those. It is state, not code: no objects, no markup, no URL and no account data, which keeps a shared element's state safe to render for every viewer.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["channel", "element_id", "revision", "values", "updated_at"],
++ "properties": {
++ "channel": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",
++ "description": "The Live channel's username (its slug)."
++ },
++ "element_id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "values": { "$ref": "#/$defs/values" },
++ "updated_at": { "type": "string", "format": "date-time" }
++ },
++ "$defs": {
++ "values": {
++ "type": "object",
++ "maxProperties": 32,
++ "propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,31}$" },
++ "additionalProperties": { "$ref": "#/$defs/value" },
++ "description": "The element's own store. A key is lower-case and 32 characters at most; a value is a boolean, a number, a string of 200 characters at most or a list of those."
++ },
++ "value": {
++ "oneOf": [
++ { "type": "boolean" },
++ { "type": "number" },
++ { "type": "string", "maxLength": 200 },
++ {
++ "type": "array",
++ "maxItems": 64,
++ "items": {
++ "oneOf": [
++ { "type": "number" },
++ { "type": "string", "maxLength": 200 }
++ ]
++ }
++ }
++ ]
++ }
++ }
++}
ddiff --git a/contracts/ui/element.v1.json b/contracts/ui/element.v1.json
nnew file mode 100644
iindex 00000000..57ed2c01
---- /dev/null
++++ b/contracts/ui/element.v1.json
@@@ -0,0 +1,203 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/element.v1.json",
++ "title": "UiElement",
++ "description": "ui.element@1: an Element people make and place on a surface: a look, a behaviour and, only when a recipe is not enough, a mod. LOOK draws from OpenVibe.Design's components and the theme's tokens, so it follows the person's theme, the seasons and dark/light; it carries no markup and no URL. RECIPE is data only: one or more triggers from ui.trigger@1, the conditions that decide who may fire it and what it costs, and one or more actions from ui.action@1, checked against CAPABILITIES. A capability is an action kind the element declares; the host refuses an action the element did not declare, and the product named by the action still checks the presser's role, the cooldown, the cost and its own limits. MOD is an optional reference to a code module (id, version and digest) for the stage where a recipe is not enough; the module never adds a power. Conditions use channel points only in stage 1: no Vibes, no money, and Billing still confirms any charge itself. The id and revision are assigned by the store; a draft has neither.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["name", "author", "sharing", "look", "recipe", "capabilities"],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Assigned by the store when the element is saved; absent in a draft."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Assigned by the store; every saved change moves it by one."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "description": {
++ "type": "string",
++ "maxLength": 500
++ },
++ "author": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["name"],
++ "properties": {
++ "name": { "type": "string", "minLength": 1, "maxLength": 80 },
++ "subject": { "$ref": "../identity/subject-ref.v1.json" }
++ },
++ "description": "The author's display name and, for a private or link element, the subject. A gallery or copy answer carries the display name only, never the account."
++ },
++ "sharing": { "enum": ["private", "link", "gallery"] },
++ "look": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["component", "label"],
++ "properties": {
++ "component": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,63}$",
++ "description": "An OpenVibe.Design component name (design.component@1), e.g. button or meter. A name the page does not have is refused, never fetched."
++ },
++ "label": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "The words on the element, e.g. Make it dance."
++ },
++ "icon": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,31}$",
++ "description": "A glyph name from the design icon set."
++ },
++ "props": {
++ "type": "object",
++ "maxProperties": 20,
++ "propertyNames": { "pattern": "^[a-z][a-zA-Z0-9]{0,31}$" },
++ "additionalProperties": {
++ "oneOf": [
++ { "type": "boolean" },
++ { "type": "number" },
++ { "type": "string", "maxLength": 200 }
++ ]
++ },
++ "description": "The design component's own props (the sliders the editor shows). Numbers, strings and booleans only; no nesting."
++ },
++ "style": { "$ref": "#/$defs/style" }
++ }
++ },
++ "recipe": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["triggers", "actions"],
++ "properties": {
++ "triggers": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 8,
++ "items": { "$ref": "trigger.v1.json" }
++ },
++ "conditions": { "$ref": "#/$defs/conditions" },
++ "actions": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "items": { "$ref": "action.v1.json" }
++ }
++ }
++ },
++ "capabilities": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "$ref": "action.v1.json#/$defs/capability" },
++ "description": "The action kinds this element may request. Every action in the recipe must be declared here; the host refuses the rest."
++ },
++ "initial_state": {
++ "$ref": "element-state.v1.json#/$defs/values",
++ "description": "The element's state before anything runs; the counter starts at 0, never at a value the element did not declare."
++ },
++ "mod": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["id", "version", "sha256"],
++ "properties": {
++ "id": { "type": "string", "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"
++ },
++ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
++ "capabilities": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "$ref": "action.v1.json#/$defs/capability" },
++ "description": "The subset of the element's capabilities the module itself may request; omitted means the element's declared capabilities, and a product may grant fewer."
++ }
++ },
++ "description": "A code module for an element whose recipe is not enough (stage 3). It runs sandboxed; the reference is data, and the host checks every request the module makes against the element's capabilities."
++ },
++ "created_at": { "type": "string", "format": "date-time" },
++ "updated_at": { "type": "string", "format": "date-time" }
++ },
++ "$defs": {
++ "style": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "tint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "A theme token, never a colour: the theme decides what it is, so contrast follows the theme."
++ },
++ "shape": { "enum": ["round", "pill", "square"] },
++ "opacity": { "type": "number", "minimum": 0.4, "maximum": 1 },
++ "show_label": { "type": "boolean" }
++ }
++ },
++ "conditions": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "role": {
++ "enum": ["everyone", "follower", "subscriber", "vip", "operator", "owner"],
++ "description": "Who may fire it. A higher role admits the lower ones where the product says so (an operator is admitted to subscriber-only); owner is the channel's owner alone. Default everyone."
++ },
++ "cost": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["points"],
++ "properties": {
++ "points": { "type": "integer", "minimum": 1, "maximum": 1000000 }
++ },
++ "description": "Channel points the presser spends, from Live's points. Stage 1 has no other currency, and an element never charges money."
++ },
++ "cooldown": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "per_person_seconds": { "type": "integer", "minimum": 0, "maximum": 86400 },
++ "overall_seconds": { "type": "integer", "minimum": 0, "maximum": 86400 }
++ },
++ "description": "How long the same person must wait, and how long everybody must wait, before it can fire again."
++ },
++ "daily_cap": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "per_person": { "type": "integer", "minimum": 1, "maximum": 10000 },
++ "overall": { "type": "integer", "minimum": 1, "maximum": 1000000 }
++ },
++ "description": "The most times it may fire in a UTC day, per person and for everyone."
++ },
++ "live_only": {
++ "type": "boolean",
++ "description": "When true the element fires only while the channel is live."
++ },
++ "robot_turn_only": {
++ "type": "boolean",
++ "description": "When true a robot action is requested only while the presser has the robot's turn; Bot still decides."
++ },
++ "chance": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["one_in"],
++ "properties": {
++ "one_in": { "type": "integer", "minimum": 1, "maximum": 1000000 }
++ },
++ "description": "Fire only one time in N. The host draws the lot, so a refused draw costs nothing."
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/ui/layout-request.v1.json b/contracts/ui/layout-request.v1.json
nnew file mode 100644
iindex 00000000..b73c33dc
---- /dev/null
++++ b/contracts/ui/layout-request.v1.json
@@@ -0,0 +1,26 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/layout-request.v1.json",
++ "title": "UiLayoutRequest",
++ "description": "ui.layout-request@1: what an editor sends OpenVibe.AI's ui.layout.suggest task, and what Network forwards for a signed-in person. It is the surface's catalog, the layout shown now (or null for the default one) and the words the person typed (max 500 characters), for one device class. The catalog is the only power the answer can use; the current layout is the starting arrangement; the words never contain markup that becomes part of it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["catalog", "layout", "words", "device"],
++ "properties": {
++ "catalog": { "$ref": "surface.v1.json" },
++ "layout": {
++ "oneOf": [
++ { "$ref": "layout.v1.json" },
++ { "type": "null" }
++ ],
++ "description": "The arrangement shown now, or null when the person is looking at the default."
++ },
++ "words": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500,
++ "description": "The person's own description, e.g. \"put Stop on the left and make it huge\"."
++ },
++ "device": { "enum": ["phone", "tablet", "computer"] }
++ }
++}
ddiff --git a/contracts/ui/layout-suggestion.v1.json b/contracts/ui/layout-suggestion.v1.json
nnew file mode 100644
iindex 00000000..a315d1fa
---- /dev/null
++++ b/contracts/ui/layout-suggestion.v1.json
@@@ -0,0 +1,54 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/layout-suggestion.v1.json",
++ "title": "UiLayoutSuggestion",
++ "description": "ui.layout-suggestion@1: OpenVibe.AI's answer to ui.layout.suggest. ok true carries the layout, already checked by openvibe-sdk/ui-layout against the catalog sent with the request, and a one-sentence summary of what changed plus the model's reasons. ok false is an honest refusal: no layout, one sentence saying what it could not do, and the validator's reasons in plain words (for example \"Stop has to stay visible.\"). A receipt names the model that answered, with the run and the route. The suggestion is previewed, never applied silently; Network or the product stores it only after the person accepts it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["ok", "layout", "summary", "reasons"],
++ "properties": {
++ "ok": { "type": "boolean" },
++ "layout": {
++ "oneOf": [
++ { "$ref": "layout.v1.json" },
++ { "type": "null" }
++ ],
++ "description": "The validated layout when ok is true; null when it is false."
++ },
++ "summary": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300,
++ "description": "One sentence saying what changed, or what could not be done."
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "items": { "type": "string", "minLength": 1, "maxLength": 300 },
++ "description": "Why this arrangement, or the validator's reasons for refusing one."
++ },
++ "receipt": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["model"],
++ "properties": {
++ "model": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "provider": { "type": "string", "minLength": 1, "maxLength": 100 },
++ "route": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "run_id": { "type": "string", "minLength": 1, "maxLength": 80 },
++ "cost_usd": { "type": "number", "minimum": 0 }
++ },
++ "description": "Which model answered: the same receipt every AI run carries, at the task's own shape."
++ }
++ },
++ "allOf": [
++ {
++ "if": { "properties": { "ok": { "const": true } }, "required": ["ok"] },
++ "then": { "properties": { "layout": { "type": "object" } } }
++ },
++ {
++ "if": { "properties": { "ok": { "const": false } }, "required": ["ok"] },
++ "then": { "properties": { "layout": { "type": "null" } } }
++ }
++ ]
++}
ddiff --git a/contracts/ui/layout.v1.json b/contracts/ui/layout.v1.json
nnew file mode 100644
iindex 00000000..973dafdc
---- /dev/null
++++ b/contracts/ui/layout.v1.json
@@@ -0,0 +1,256 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/layout.v1.json",
++ "title": "UiLayout",
++ "description": "ui.layout@1: one person's arrangement of one surface on one device class. A layout names the surface and its catalog version, the device, its own name, its author and its sharing (private, link or gallery), and one entry per part it places. A part is visible or not, sits on the surface's grid (x, y, w, h in cells) or docks to an edge for an overlay, and carries a style (a theme token tint, a shape, opacity 0.4-1 and the label) and bindings. It is data only: no URL, no markup and no code may appear anywhere in it, and every part id, tint, shape, opacity, size and binding must be one the catalog allows. openvibe-sdk/ui-layout validates it before any server, editor or AI answer stores or shows it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "surface",
++ "surface_version",
++ "device",
++ "name",
++ "author",
++ "sharing",
++ "parts"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^lay_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Assigned by OpenVibe.Network when the layout is stored; absent in a draft, a share answer or an AI suggestion."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Assigned by the store; every change to a stored layout moves it by one."
++ },
++ "active": {
++ "type": "boolean",
++ "description": "Set by OpenVibe.Network: the one layout a product draws for this surface on this device class. A draft, a share answer and an AI suggestion carry no active field."
++ },
++ "surface": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80
++ },
++ "surface_version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "The person's name for this layout; \"My phone controls\" is fine."
++ },
++ "author": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "subject": {
++ "$ref": "../identity/subject-ref.v1.json"
++ }
++ },
++ "description": "The author's display name and, for a private or link layout, the subject. A share or gallery answer carries the display name only, never the account."
++ },
++ "sharing": {
++ "enum": [
++ "private",
++ "link",
++ "gallery"
++ ]
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": {
++ "$ref": "#/$defs/part"
++ }
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "place": {
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "x",
++ "y",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "x": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 23
++ },
++ "y": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "The part's cell on the surface grid."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "dock",
++ "order",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "dock": {
++ "enum": [
++ "left",
++ "right",
++ "top",
++ "bottom"
++ ]
++ },
++ "order": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 63
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "For an overlay: the edge the part docks to, its order along that edge, and its size in cells."
++ }
++ ]
++ },
++ "style": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "tint",
++ "shape",
++ "opacity",
++ "label"
++ ],
++ "properties": {
++ "tint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "A theme token, never a colour: the theme decides what it is, so contrast follows the theme."
++ },
++ "shape": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ },
++ "opacity": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "label": {
++ "type": "boolean"
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "keys",
++ "gamepad"
++ ],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ }
++ }
++ }
++ },
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "visible",
++ "place",
++ "style",
++ "bindings"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "visible": {
++ "type": "boolean"
++ },
++ "place": {
++ "$ref": "#/$defs/place"
++ },
++ "style": {
++ "$ref": "#/$defs/style"
++ },
++ "bindings": {
++ "$ref": "#/$defs/bindings"
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/ui/surface.v1.json b/contracts/ui/surface.v1.json
nnew file mode 100644
iindex 00000000..47c10507
---- /dev/null
++++ b/contracts/ui/surface.v1.json
@@@ -0,0 +1,200 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/surface.v1.json",
++ "title": "UiSurface",
++ "description": "ui.surface@1: the catalog a product publishes for one surface people may arrange (bot.controls, bot.overlay, live.channel, dock, chat.composer, navbar.shortcuts). It names the surface, its device classes and grid, the theme tokens its styles draw from, and every part a layout may place. A part says what it is (stick, button, slider, readout, video, list), whether it is required or pressed, its minimum cells and the minimum pixels the product guarantees (44 px for anything pressed, larger for Stop), the sizes and styles it allows, the bindings it accepts, and the parts it must never cover. A layout is validated against this catalog (openvibe-sdk/ui-layout); a layout can arrange only what the catalog has and can never add a power. The catalog is data: a layout or an AI answer never carries code or a URL.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["id", "version", "name", "devices", "grid", "theme_tokens", "parts"],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80,
++ "description": "The surface id: <product>.<surface>, e.g. bot.controls."
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The catalog version. A layout names the version it was made against."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "What the surface is called where a person sees it."
++ },
++ "description": {
++ "type": "string",
++ "maxLength": 500
++ },
++ "devices": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 3,
++ "uniqueItems": true,
++ "items": { "enum": ["phone", "tablet", "computer"] },
++ "description": "The device classes this surface offers."
++ },
++ "grid": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["columns", "cell_px"],
++ "properties": {
++ "columns": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["phone", "tablet", "computer"],
++ "description": "The grid's columns per device class: 4 on a phone and 12 on a computer in the design; a product may choose otherwise within these bounds.",
++ "properties": {
++ "phone": { "type": "integer", "minimum": 2, "maximum": 8 },
++ "tablet": { "type": "integer", "minimum": 2, "maximum": 16 },
++ "computer": { "type": "integer", "minimum": 2, "maximum": 24 }
++ }
++ },
++ "cell_px": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["phone", "tablet", "computer"],
++ "description": "The smallest a cell is rendered at that device class, in CSS pixels, at the narrowest supported width. The validator multiplies a part's cells by this to check the 44 px rule.",
++ "properties": {
++ "phone": { "type": "integer", "minimum": 24, "maximum": 200 },
++ "tablet": { "type": "integer", "minimum": 24, "maximum": 200 },
++ "computer": { "type": "integer", "minimum": 24, "maximum": 200 }
++ }
++ }
++ }
++ },
++ "theme_tokens": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "description": "The theme tokens a tint may name (accent, surface, danger, ...). Never a colour value: the theme decides what the token is."
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": { "$ref": "#/$defs/part" }
++ }
++ },
++ "$defs": {
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["id", "name", "kind", "required", "pressed", "min", "styles", "bindings"],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$",
++ "description": "Stable part id inside this catalog, e.g. stop, stick, battery."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "kind": { "enum": ["stick", "button", "slider", "readout", "video", "list"] },
++ "required": {
++ "type": "boolean",
++ "description": "A required part is always visible and at its minimum size (Stop and the e-stop). A layout that hides or shrinks it is refused."
++ },
++ "pressed": {
++ "type": "boolean",
++ "description": "The part sends a command when pressed or moved; it must render at least 44 px on every side."
++ },
++ "min": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["w", "h"],
++ "description": "The smallest size a layout may give the part: w and h in grid cells, and px when the product guarantees more than 44 px (Stop).",
++ "properties": {
++ "w": { "type": "integer", "minimum": 1, "maximum": 24 },
++ "h": { "type": "integer", "minimum": 1, "maximum": 24 },
++ "px": { "type": "integer", "minimum": 1, "maximum": 400 }
++ }
++ },
++ "sizes": {
++ "type": "array",
++ "maxItems": 12,
++ "uniqueItems": true,
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["w", "h"],
++ "properties": {
++ "w": { "type": "integer", "minimum": 1, "maximum": 24 },
++ "h": { "type": "integer", "minimum": 1, "maximum": 24 }
++ }
++ },
++ "description": "Sizes the editor may offer as presets. A layout is not limited to these: it is limited to min and to the grid."
++ },
++ "styles": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["tints", "shapes"],
++ "properties": {
++ "tints": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "description": "The theme tokens this part may be tinted with; every one is also in the surface's theme_tokens."
++ },
++ "shapes": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 3,
++ "uniqueItems": true,
++ "items": { "enum": ["round", "pill", "square"] }
++ },
++ "opacity": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["min", "max"],
++ "properties": {
++ "min": { "type": "number", "minimum": 0.4, "maximum": 1 },
++ "max": { "type": "number", "minimum": 0.4, "maximum": 1 }
++ },
++ "description": "The opacity range this part allows; the design's range is 0.4 to 1."
++ },
++ "label": {
++ "type": "boolean",
++ "description": "Whether the editor may show the part's label on it."
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["keys", "gamepad"],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[A-Za-z0-9+_.-]{1,40}$" }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,39}$" }
++ }
++ },
++ "description": "The bindings the product accepts for this part. A layout may pick a subset, never add one."
++ },
++ "never_covers": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,39}$" },
++ "description": "The parts this part must never cover, in addition to the surface-wide rule that nothing covers a required part."
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/ui/trigger.v1.json b/contracts/ui/trigger.v1.json
nnew file mode 100644
iindex 00000000..84020577
---- /dev/null
++++ b/contracts/ui/trigger.v1.json
@@@ -0,0 +1,202 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/trigger.v1.json",
++ "title": "UiTrigger",
++ "description": "ui.trigger@1: one trigger an Element's recipe listens for, from the fixed catalog. kind says what happened: a viewer pressing, holding or double-pressing the element, a key or gamepad binding, a chat command or text, a follow, a tip, an AI Moment, a Rundown topic, a timer or schedule, robot telemetry or an event, the stream starting or ending, a viewer-count crossing or another element's event. The fields a kind needs are the only fields it accepts. A trigger is data: it never names code or a URL, and a product may refuse a kind it does not offer yet. Stage 1 of Elements uses press and chat_command; the rest are declared here so a stage-2 element and the editor speak one language.",
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "press" }
++ },
++ "description": "The element itself was pressed. This is the Viewer Deck's button."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "ms"],
++ "properties": {
++ "kind": { "const": "hold" },
++ "ms": { "type": "integer", "minimum": 300, "maximum": 10000 }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "double_press" },
++ "within_ms": { "type": "integer", "minimum": 100, "maximum": 2000, "description": "The longest gap between the two presses; 400 by default." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "key"],
++ "properties": {
++ "kind": { "const": "key" },
++ "key": { "type": "string", "pattern": "^[A-Za-z0-9+_.-]{1,40}$" },
++ "modifiers": {
++ "type": "array",
++ "maxItems": 4,
++ "uniqueItems": true,
++ "items": { "enum": ["ctrl", "alt", "shift", "meta"] }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "button"],
++ "properties": {
++ "kind": { "const": "gamepad" },
++ "button": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,39}$" },
++ "index": { "type": "integer", "minimum": 0, "maximum": 7, "description": "Which connected pad; omitted means any." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "command"],
++ "properties": {
++ "kind": { "const": "chat_command" },
++ "command": { "type": "string", "pattern": "^![a-z0-9_-]{1,32}$", "description": "The command without arguments, lower case, e.g. !dance." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "words"],
++ "properties": {
++ "kind": { "const": "chat_text" },
++ "words": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "uniqueItems": true,
++ "items": { "type": "string", "minLength": 1, "maxLength": 40 }
++ },
++ "mode": { "enum": ["any", "all"], "description": "any (default) matches one of the words; all matches every one." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "follow" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "tip" },
++ "min": { "type": "integer", "minimum": 0, "description": "The smallest tip, in Vibes, that fires the trigger; omitted means any tip." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "ai_moment" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "rundown_topic" },
++ "words": {
++ "type": "array",
++ "maxItems": 8,
++ "uniqueItems": true,
++ "items": { "type": "string", "minLength": 1, "maxLength": 40 },
++ "description": "Optional words the topic has to mention; omitted means any topic."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "every_seconds"],
++ "properties": {
++ "kind": { "const": "timer" },
++ "every_seconds": { "type": "integer", "minimum": 5, "maximum": 86400 },
++ "start": { "enum": ["live", "always"], "description": "live (default) counts only while the channel is live; always counts from the element being active." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "cron", "timezone"],
++ "properties": {
++ "kind": { "const": "schedule" },
++ "cron": { "type": "string", "pattern": "^[0-9*/, -]{9,80}$", "description": "A five-field cron in the person's timezone." },
++ "timezone": { "type": "string", "minLength": 1, "maxLength": 64 }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "metric", "op", "value"],
++ "properties": {
++ "kind": { "const": "robot_telemetry" },
++ "metric": { "enum": ["battery_percent", "latency_ms", "stuck", "temperature_c"] },
++ "op": { "enum": ["lt", "gt"] },
++ "value": { "type": "number" },
++ "for_seconds": { "type": "integer", "minimum": 0, "maximum": 3600, "description": "How long the condition must hold; 0 (default) means at once." }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "event"],
++ "properties": {
++ "kind": { "const": "robot_event" },
++ "event": { "enum": ["estop", "stopped", "disconnected", "command_refused", "button"] }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "stream_start" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind"],
++ "properties": {
++ "kind": { "const": "stream_end" }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "op", "count"],
++ "properties": {
++ "kind": { "const": "viewer_count" },
++ "op": { "enum": ["above", "below"] },
++ "count": { "type": "integer", "minimum": 0, "maximum": 100000000 }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": ["kind", "element_id"],
++ "properties": {
++ "kind": { "const": "element_event" },
++ "element_id": { "type": "string", "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "event": { "enum": ["pressed", "state", "shown"], "description": "pressed (default) fires after that element ran; state and shown fire when Live broadcasts them." }
++ }
++ }
++ ]
++}
ddiff --git a/fixtures/ai.deep-dive/invalid/unknown-status.json b/fixtures/ai.deep-dive/invalid/unknown-status.json
nnew file mode 100644
iindex 00000000..61ce6558
---- /dev/null
++++ b/fixtures/ai.deep-dive/invalid/unknown-status.json
@@@ -0,0 +1,28 @@
++{
++ "id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "schema": "ai.deep-dive@1",
++ "run_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "status": "finished",
++ "input": { "question": "a phone-first robot control panel", "depth": "quick", "max_sites": 6 },
++ "steps": [],
++ "sources": [],
++ "captures": [],
++ "analysis": [],
++ "synthesis": {
++ "patterns": [],
++ "comparison": { "columns": [], "rows": [] },
++ "best": { "source_id": "src_1", "what": "x", "evidence": [] },
++ "mistakes": [],
++ "gap": "",
++ "reference_tokens": { "palettes": [], "type_scales": [], "spacing": [] },
++ "do": [],
++ "dont": [],
++ "accessibility": [],
++ "speed": []
++ },
++ "recommendations": [],
++ "receipt": { "price_usd": 0.2, "held_usd": 0.2, "cost_usd": 0, "models": [], "captures_count": 0, "synthetic": false },
++ "gaps": [],
++ "created_at": "2026-11-11T10:00:00.000Z",
++ "updated_at": "2026-11-11T10:03:21.000Z"
++}
ddiff --git a/fixtures/ai.deep-dive/valid/quick-robot-panel.json b/fixtures/ai.deep-dive/valid/quick-robot-panel.json
nnew file mode 100644
iindex 00000000..5938cc20
---- /dev/null
++++ b/fixtures/ai.deep-dive/valid/quick-robot-panel.json
@@@ -0,0 +1,139 @@
++{
++ "id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "schema": "ai.deep-dive@1",
++ "run_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "status": "succeeded",
++ "input": {
++ "question": "a phone-first robot control panel",
++ "depth": "quick",
++ "max_sites": 6,
++ "sites": ["https://example.com/robot"]
++ },
++ "steps": [
++ { "key": "plan", "status": "done", "detail": "wrote 9 expert questions", "started_at": "2026-11-11T10:00:00.000Z", "finished_at": "2026-11-11T10:00:02.000Z" },
++ { "key": "search", "status": "done", "detail": "4 engines, 12 sources", "started_at": "2026-11-11T10:00:02.000Z", "finished_at": "2026-11-11T10:00:40.000Z" },
++ { "key": "choose", "status": "done", "detail": "6 sites chosen", "started_at": "2026-11-11T10:00:40.000Z", "finished_at": "2026-11-11T10:00:42.000Z" },
++ { "key": "capture", "status": "done", "detail": "12 captures", "started_at": "2026-11-11T10:00:42.000Z", "finished_at": "2026-11-11T10:02:30.000Z" },
++ { "key": "analyse", "status": "done", "detail": "6 sites read", "started_at": "2026-11-11T10:02:30.000Z", "finished_at": "2026-11-11T10:03:05.000Z" },
++ { "key": "synthesise", "status": "done", "detail": "8 patterns", "started_at": "2026-11-11T10:03:05.000Z", "finished_at": "2026-11-11T10:03:20.000Z" },
++ { "key": "report", "status": "done", "detail": "report ready", "started_at": "2026-11-11T10:03:20.000Z", "finished_at": "2026-11-11T10:03:21.000Z" }
++ ],
++ "sources": [
++ {
++ "id": "src_1",
++ "kind": "web",
++ "title": "A phone-first robot control panel",
++ "url": "https://example.com/robot",
++ "publisher": "example.com",
++ "engine": "openvibe",
++ "snippet": "A control panel laid out for one thumb.",
++ "license": null,
++ "read_at": "2026-11-11T10:00:10.000Z"
++ }
++ ],
++ "captures": [
++ {
++ "id": "cap_1",
++ "source_id": "src_1",
++ "url": "https://example.com/robot",
++ "final_url": "https://example.com/robot",
++ "title": "A phone-first robot control panel",
++ "captured_at": "2026-11-11T10:00:45.000Z",
++ "viewport": 390,
++ "theme": "light",
++ "full_page": true,
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "width": 390,
++ "height": 2400,
++ "crops": [
++ { "id": "crop_1", "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPR", "y": 0, "height": 844 },
++ { "id": "crop_2", "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS", "y": 844, "height": 844 }
++ ],
++ "dom": { "fonts": { "families": ["Inter"], "sizes": ["14px", "16px", "28px"] }, "colours": [{ "value": "rgb(12, 14, 18)", "share": 0.71 }] },
++ "extras": null,
++ "skip": null,
++ "cached": false
++ }
++ ],
++ "analysis": [
++ {
++ "source_id": "src_1",
++ "url": "https://example.com/robot",
++ "summary": "One thumb reaches every primary control; the live camera fills the first screen.",
++ "first_impression": "The robot's camera frame is the page.",
++ "hierarchy": "One headline, one live frame, then the controls.",
++ "density": "Spacious; four controls per screen.",
++ "type": "One family, three sizes.",
++ "colour": "Near-black on white with a single amber action.",
++ "motion": "The frame pulses when connected.",
++ "words": "Verbs on buttons, state in the status line.",
++ "strengths": ["Every control sits in the bottom third", "State is written, not only coloured"],
++ "weaknesses": ["The emergency stop is the same size as the rest"],
++ "accessibility": "The stop button has no accessible name.",
++ "speed": "The first frame arrived once the socket opened; no skeleton.",
++ "evidence": [
++ { "claim": "Controls sit in the bottom third", "source_id": "src_1", "capture_id": "cap_1", "crop_id": "crop_2", "url": "https://example.com/robot", "read_at": "2026-11-11T10:00:45.000Z" },
++ { "claim": "The stop button has no accessible name", "source_id": "src_1", "capture_id": "cap_1", "crop_id": null, "url": "https://example.com/robot", "read_at": "2026-11-11T10:00:45.000Z" }
++ ]
++ }
++ ],
++ "synthesis": {
++ "patterns": [
++ {
++ "name": "Live view first",
++ "what": "The camera or state view is the whole first screen, controls below it.",
++ "sites": ["src_1"],
++ "usage_count": 1,
++ "evidence": [{ "claim": "The frame is the first screen", "source_id": "src_1", "capture_id": "cap_1", "crop_id": "crop_1", "url": "https://example.com/robot", "read_at": "2026-11-11T10:00:45.000Z" }]
++ }
++ ],
++ "comparison": {
++ "columns": ["Site", "First screen", "Controls"],
++ "rows": [["example.com", "live frame", "bottom third"]]
++ },
++ "best": {
++ "source_id": "src_1",
++ "what": "The live frame doubles as the connection state.",
++ "evidence": [{ "claim": "The frame carries the status line", "source_id": "src_1", "capture_id": "cap_1", "crop_id": "crop_1", "url": "https://example.com/robot", "read_at": "2026-11-11T10:00:45.000Z" }]
++ },
++ "mistakes": [
++ { "what": "The emergency stop is not separated from the other controls.", "evidence": [{ "claim": "All buttons share one row", "source_id": "src_1", "capture_id": "cap_1", "crop_id": "crop_2", "url": "https://example.com/robot", "read_at": "2026-11-11T10:00:45.000Z" }] }
++ ],
++ "gap": "No control panel shows what the robot is about to do before the tap is sent.",
++ "reference_tokens": {
++ "palettes": [{ "source_id": "src_1", "colours": ["#0c0e12", "#ffffff", "#f5a623"] }],
++ "type_scales": [{ "source_id": "src_1", "families": ["Inter"], "sizes": ["14px", "16px", "28px"] }],
++ "spacing": [{ "source_id": "src_1", "steps": ["4px", "8px", "16px", "24px"] }]
++ },
++ "do": ["Keep every primary control in the thumb reach of one hand"],
++ "dont": ["Do not make the stop control share the row of ordinary actions"],
++ "accessibility": ["Name every icon-only control"],
++ "speed": ["Show the frame before the socket finishes connecting"]
++ },
++ "recommendations": [
++ {
++ "id": "rec_1",
++ "what": "Put the emergency stop alone, full width, at the bottom.",
++ "why": "The best panel suggests it by layout; the failure here is that it does not.",
++ "rule": { "id": "control.destructive-separation", "status": "follows", "text": "Destructive actions are separated from ordinary ones." },
++ "evidence": [{ "claim": "The stop shares the row", "source_id": "src_1", "capture_id": "cap_1", "crop_id": "crop_2", "url": "https://example.com/robot", "read_at": "2026-11-11T10:00:45.000Z" }]
++ }
++ ],
++ "receipt": {
++ "price_usd": 0.2,
++ "held_usd": 0.2,
++ "cost_usd": 0.014,
++ "captures_count": 12,
++ "synthetic": false,
++ "models": [
++ { "step": "plan", "provider": "deepseek", "model": "deepseek-v4-flash", "tokens_in": 900, "tokens_out": 320, "cost_usd": 0.0004, "ms": 1800 },
++ { "step": "choose", "provider": "deepseek", "model": "deepseek-v4-flash", "tokens_in": 2400, "tokens_out": 500, "cost_usd": 0.0007, "ms": 2100 },
++ { "step": "analyse", "provider": "deepseek", "model": "deepseek-v4-pro", "tokens_in": 12000, "tokens_out": 4000, "cost_usd": 0.012, "ms": 26000 },
++ { "step": "synthesise", "provider": "deepseek", "model": "deepseek-v4-pro", "tokens_in": 6000, "tokens_out": 2500, "cost_usd": 0.007, "ms": 12000 }
++ ]
++ },
++ "gaps": ["No Expert extras were measured at Quick depth."],
++ "created_at": "2026-11-11T10:00:00.000Z",
++ "updated_at": "2026-11-11T10:03:21.000Z",
++ "finished_at": "2026-11-11T10:03:21.000Z"
++}
ddiff --git a/fixtures/ai.deep_dive.updated/invalid/progress-over-one.json b/fixtures/ai.deep_dive.updated/invalid/progress-over-one.json
nnew file mode 100644
iindex 00000000..382dd984
---- /dev/null
++++ b/fixtures/ai.deep_dive.updated/invalid/progress-over-one.json
@@@ -0,0 +1,14 @@
++{
++ "deep_dive_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "run_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "status": "running",
++ "step": "capture",
++ "progress": 4.5,
++ "steps": [],
++ "depth": "quick",
++ "requester": { "type": "service", "id": "exchange" },
++ "on_behalf_of": null,
++ "url": "https://openvibe.exchange/deep-dive/run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "error": null,
++ "occurred_at": "2026-11-11T10:01:00.000Z"
++}
ddiff --git a/fixtures/ai.deep_dive.updated/valid/capturing.json b/fixtures/ai.deep_dive.updated/valid/capturing.json
nnew file mode 100644
iindex 00000000..340e3fb7
---- /dev/null
++++ b/fixtures/ai.deep_dive.updated/valid/capturing.json
@@@ -0,0 +1,22 @@
++{
++ "deep_dive_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "run_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "status": "running",
++ "step": "capture",
++ "progress": 0.5,
++ "steps": [
++ { "key": "plan", "status": "done" },
++ { "key": "search", "status": "done" },
++ { "key": "choose", "status": "done" },
++ { "key": "capture", "status": "running" },
++ { "key": "analyse", "status": "pending" },
++ { "key": "synthesise", "status": "pending" },
++ { "key": "report", "status": "pending" }
++ ],
++ "depth": "quick",
++ "requester": { "type": "service", "id": "exchange" },
++ "on_behalf_of": { "type": "user", "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ" },
++ "url": "https://openvibe.exchange/deep-dive/run_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "error": null,
++ "occurred_at": "2026-11-11T10:01:00.000Z"
++}
ddiff --git a/fixtures/live.element.pressed/invalid/bad-presser.json b/fixtures/live.element.pressed/invalid/bad-presser.json
nnew file mode 100644
iindex 00000000..2a53e15f
---- /dev/null
++++ b/fixtures/live.element.pressed/invalid/bad-presser.json
@@@ -0,0 +1,17 @@
++{
++ "channel": {
++ "username": "ada",
++ "display_name": "Ada"
++ },
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "element_name": "Make it dance",
++ "presser": {
++ "type": "user",
++ "id": "alice"
++ },
++ "presser_name": "Alice",
++ "trigger": {
++ "kind": "press"
++ },
++ "at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/live.element.pressed/invalid/missing-element.json b/fixtures/live.element.pressed/invalid/missing-element.json
nnew file mode 100644
iindex 00000000..0270ed39
---- /dev/null
++++ b/fixtures/live.element.pressed/invalid/missing-element.json
@@@ -0,0 +1,15 @@
++{
++ "channel": {
++ "username": "ada",
++ "display_name": "Ada"
++ },
++ "presser": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "presser_name": "Maticus",
++ "trigger": {
++ "kind": "press"
++ },
++ "at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/live.element.pressed/valid/chat-command.json b/fixtures/live.element.pressed/valid/chat-command.json
nnew file mode 100644
iindex 00000000..aca2f4c1
---- /dev/null
++++ b/fixtures/live.element.pressed/valid/chat-command.json
@@@ -0,0 +1,20 @@
++{
++ "channel": {
++ "username": "ada",
++ "display_name": "Ada"
++ },
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "element_name": "Headlight tally",
++ "presser": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "presser_name": "Maticus",
++ "trigger": {
++ "kind": "chat_command",
++ "command": "!headlights"
++ },
++ "points_spent": 0,
++ "state_revision": 2,
++ "at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/live.element.pressed/valid/press.json b/fixtures/live.element.pressed/valid/press.json
nnew file mode 100644
iindex 00000000..e90d35e2
---- /dev/null
++++ b/fixtures/live.element.pressed/valid/press.json
@@@ -0,0 +1,19 @@
++{
++ "channel": {
++ "username": "ada",
++ "display_name": "Ada"
++ },
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "element_name": "Make it dance",
++ "presser": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "presser_name": "Maticus",
++ "trigger": {
++ "kind": "press"
++ },
++ "points_spent": 50,
++ "state_revision": 4,
++ "at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/live.element.state/invalid/nested-value.json b/fixtures/live.element.state/invalid/nested-value.json
nnew file mode 100644
iindex 00000000..2cceee18
---- /dev/null
++++ b/fixtures/live.element.state/invalid/nested-value.json
@@@ -0,0 +1,16 @@
++{
++ "channel": {
++ "username": "ada",
++ "display_name": "Ada"
++ },
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 1,
++ "values": {
++ "wheel": {
++ "entries": [
++ "a"
++ ]
++ }
++ },
++ "updated_at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/live.element.state/valid/state.json b/fixtures/live.element.state/valid/state.json
nnew file mode 100644
iindex 00000000..d98afc88
---- /dev/null
++++ b/fixtures/live.element.state/valid/state.json
@@@ -0,0 +1,19 @@
++{
++ "channel": {
++ "username": "ada",
++ "display_name": "Ada"
++ },
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 4,
++ "values": {
++ "dances": 3,
++ "last_presser": "Maticus",
++ "wheel": [
++ "a",
++ "b",
++ 3
++ ],
++ "lit": true
++ },
++ "updated_at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/network.layout.changed/invalid/unknown-change.json b/fixtures/network.layout.changed/invalid/unknown-change.json
nnew file mode 100644
iindex 00000000..eed60aaf
---- /dev/null
++++ b/fixtures/network.layout.changed/invalid/unknown-change.json
@@@ -0,0 +1,7 @@
++{
++ "surface": "bot.controls",
++ "device": "phone",
++ "layout_id": "lay_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "change": "applied",
++ "revision": 3
++}
ddiff --git a/fixtures/network.layout.changed/valid/activated.json b/fixtures/network.layout.changed/valid/activated.json
nnew file mode 100644
iindex 00000000..8172c4f5
---- /dev/null
++++ b/fixtures/network.layout.changed/valid/activated.json
@@@ -0,0 +1,8 @@
++{
++ "surface": "bot.controls",
++ "device": "phone",
++ "layout_id": "lay_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "change": "active",
++ "revision": 3,
++ "sharing": "private"
++}
ddiff --git a/fixtures/network.layout.changed/valid/published.json b/fixtures/network.layout.changed/valid/published.json
nnew file mode 100644
iindex 00000000..8d0b748c
---- /dev/null
++++ b/fixtures/network.layout.changed/valid/published.json
@@@ -0,0 +1,8 @@
++{
++ "surface": "bot.controls",
++ "device": "computer",
++ "layout_id": "lay_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "change": "published",
++ "revision": 7,
++ "sharing": "gallery"
++}
ddiff --git a/fixtures/ui.action/invalid/missing-text.json b/fixtures/ui.action/invalid/missing-text.json
nnew file mode 100644
iindex 00000000..ac211777
---- /dev/null
++++ b/fixtures/ui.action/invalid/missing-text.json
@@@ -0,0 +1,4 @@
++{
++ "kind": "robot.say",
++ "product": "bot"
++}
ddiff --git a/fixtures/ui.action/invalid/unknown-kind.json b/fixtures/ui.action/invalid/unknown-kind.json
nnew file mode 100644
iindex 00000000..59464453
---- /dev/null
++++ b/fixtures/ui.action/invalid/unknown-kind.json
@@@ -0,0 +1,4 @@
++{
++ "kind": "robot.dance",
++ "product": "bot"
++}
ddiff --git a/fixtures/ui.action/invalid/wrong-product.json b/fixtures/ui.action/invalid/wrong-product.json
nnew file mode 100644
iindex 00000000..07319ea9
---- /dev/null
++++ b/fixtures/ui.action/invalid/wrong-product.json
@@@ -0,0 +1,6 @@
++{
++ "kind": "robot.drive_pulse",
++ "product": "live",
++ "direction": "forward",
++ "ms": 600
++}
ddiff --git a/fixtures/ui.action/valid/chat-message.json b/fixtures/ui.action/valid/chat-message.json
nnew file mode 100644
iindex 00000000..e9e8ea62
---- /dev/null
++++ b/fixtures/ui.action/valid/chat-message.json
@@@ -0,0 +1,5 @@
++{
++ "kind": "chat.message",
++ "product": "chat",
++ "text": "Nice one!"
++}
ddiff --git a/fixtures/ui.action/valid/robot-pulse.json b/fixtures/ui.action/valid/robot-pulse.json
nnew file mode 100644
iindex 00000000..6f5affea
---- /dev/null
++++ b/fixtures/ui.action/valid/robot-pulse.json
@@@ -0,0 +1,6 @@
++{
++ "kind": "robot.drive_pulse",
++ "product": "bot",
++ "direction": "forward",
++ "ms": 600
++}
ddiff --git a/fixtures/ui.action/valid/screen-alert.json b/fixtures/ui.action/valid/screen-alert.json
nnew file mode 100644
iindex 00000000..84179038
---- /dev/null
++++ b/fixtures/ui.action/valid/screen-alert.json
@@@ -0,0 +1,6 @@
++{
++ "kind": "screen.alert",
++ "product": "live",
++ "title": "Dance!",
++ "text": "Ada made the robot dance."
++}
ddiff --git a/fixtures/ui.element-state/invalid/nested-value.json b/fixtures/ui.element-state/invalid/nested-value.json
nnew file mode 100644
iindex 00000000..e3d0f62c
---- /dev/null
++++ b/fixtures/ui.element-state/invalid/nested-value.json
@@@ -0,0 +1,13 @@
++{
++ "channel": "ada",
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 1,
++ "values": {
++ "wheel": {
++ "entries": [
++ "a"
++ ]
++ }
++ },
++ "updated_at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/ui.element-state/invalid/uppercase-key.json b/fixtures/ui.element-state/invalid/uppercase-key.json
nnew file mode 100644
iindex 00000000..91cdc2b7
---- /dev/null
++++ b/fixtures/ui.element-state/invalid/uppercase-key.json
@@@ -0,0 +1,9 @@
++{
++ "channel": "ada",
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 1,
++ "values": {
++ "Dances": 1
++ },
++ "updated_at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/ui.element-state/valid/counter.json b/fixtures/ui.element-state/valid/counter.json
nnew file mode 100644
iindex 00000000..0339c056
---- /dev/null
++++ b/fixtures/ui.element-state/valid/counter.json
@@@ -0,0 +1,16 @@
++{
++ "channel": "ada",
++ "element_id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 4,
++ "values": {
++ "dances": 3,
++ "last_presser": "Maticus",
++ "wheel": [
++ "a",
++ "b",
++ 3
++ ],
++ "lit": true
++ },
++ "updated_at": "2026-11-11T20:31:00.000Z"
++}
ddiff --git a/fixtures/ui.element/invalid/missing-actions.json b/fixtures/ui.element/invalid/missing-actions.json
nnew file mode 100644
iindex 00000000..d0e19565
---- /dev/null
++++ b/fixtures/ui.element/invalid/missing-actions.json
@@@ -0,0 +1,21 @@
++{
++ "name": "Bad",
++ "author": {
++ "name": "Ada"
++ },
++ "sharing": "private",
++ "look": {
++ "component": "button",
++ "label": "Bad"
++ },
++ "recipe": {
++ "triggers": [
++ {
++ "kind": "press"
++ }
++ ]
++ },
++ "capabilities": [
++ "robot.say"
++ ]
++}
ddiff --git a/fixtures/ui.element/invalid/nested-state.json b/fixtures/ui.element/invalid/nested-state.json
nnew file mode 100644
iindex 00000000..e4b9138e
---- /dev/null
++++ b/fixtures/ui.element/invalid/nested-state.json
@@@ -0,0 +1,35 @@
++{
++ "name": "Bad",
++ "author": {
++ "name": "Ada"
++ },
++ "sharing": "private",
++ "look": {
++ "component": "button",
++ "label": "Bad"
++ },
++ "recipe": {
++ "triggers": [
++ {
++ "kind": "press"
++ }
++ ],
++ "actions": [
++ {
++ "kind": "robot.say",
++ "product": "bot",
++ "text": "hi"
++ }
++ ]
++ },
++ "capabilities": [
++ "robot.say"
++ ],
++ "initial_state": {
++ "wheel": {
++ "entries": [
++ "a"
++ ]
++ }
++ }
++}
ddiff --git a/fixtures/ui.element/invalid/unknown-action.json b/fixtures/ui.element/invalid/unknown-action.json
nnew file mode 100644
iindex 00000000..3bfde4ab
---- /dev/null
++++ b/fixtures/ui.element/invalid/unknown-action.json
@@@ -0,0 +1,27 @@
++{
++ "name": "Bad",
++ "author": {
++ "name": "Ada"
++ },
++ "sharing": "private",
++ "look": {
++ "component": "button",
++ "label": "Bad"
++ },
++ "recipe": {
++ "triggers": [
++ {
++ "kind": "press"
++ }
++ ],
++ "actions": [
++ {
++ "kind": "robot.dance",
++ "product": "bot"
++ }
++ ]
++ },
++ "capabilities": [
++ "robot.say"
++ ]
++}
ddiff --git a/fixtures/ui.element/valid/chat-counter.json b/fixtures/ui.element/valid/chat-counter.json
nnew file mode 100644
iindex 00000000..c15847ec
---- /dev/null
++++ b/fixtures/ui.element/valid/chat-counter.json
@@@ -0,0 +1,52 @@
++{
++ "name": "Headlight tally",
++ "author": {
++ "name": "Ada"
++ },
++ "sharing": "private",
++ "look": {
++ "component": "counter",
++ "label": "Headlights",
++ "style": {
++ "tint": "surface",
++ "shape": "square",
++ "opacity": 0.8,
++ "show_label": true
++ }
++ },
++ "recipe": {
++ "triggers": [
++ {
++ "kind": "chat_command",
++ "command": "!headlights"
++ }
++ ],
++ "conditions": {
++ "role": "everyone",
++ "cooldown": {
++ "per_person_seconds": 30,
++ "overall_seconds": 5
++ }
++ },
++ "actions": [
++ {
++ "kind": "screen.counter",
++ "product": "live",
++ "key": "headlights",
++ "by": 1
++ },
++ {
++ "kind": "chat.message",
++ "product": "chat",
++ "text": "Headlights on!"
++ }
++ ]
++ },
++ "capabilities": [
++ "screen.counter",
++ "chat.message"
++ ],
++ "initial_state": {
++ "headlights": 0
++ }
++}
ddiff --git a/fixtures/ui.element/valid/dance-button.json b/fixtures/ui.element/valid/dance-button.json
nnew file mode 100644
iindex 00000000..4db721a5
---- /dev/null
++++ b/fixtures/ui.element/valid/dance-button.json
@@@ -0,0 +1,75 @@
++{
++ "id": "el_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "revision": 3,
++ "name": "Make it dance",
++ "description": "The robot spins for a moment and confetti follows.",
++ "author": {
++ "name": "Maticus",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "sharing": "gallery",
++ "look": {
++ "component": "button",
++ "label": "Make it dance",
++ "icon": "robot",
++ "props": {
++ "size": "large"
++ },
++ "style": {
++ "tint": "accent",
++ "shape": "pill",
++ "opacity": 1,
++ "show_label": true
++ }
++ },
++ "recipe": {
++ "triggers": [
++ {
++ "kind": "press"
++ }
++ ],
++ "conditions": {
++ "role": "follower",
++ "cost": {
++ "points": 50
++ },
++ "cooldown": {
++ "per_person_seconds": 60,
++ "overall_seconds": 10
++ },
++ "live_only": true,
++ "daily_cap": {
++ "per_person": 5
++ },
++ "chance": {
++ "one_in": 1
++ }
++ },
++ "actions": [
++ {
++ "kind": "robot.drive_pulse",
++ "product": "bot",
++ "direction": "spin_left",
++ "ms": 800,
++ "speed": 0.5
++ },
++ {
++ "kind": "screen.confetti",
++ "product": "live",
++ "intensity": "medium"
++ }
++ ]
++ },
++ "capabilities": [
++ "robot.drive_pulse",
++ "screen.confetti"
++ ],
++ "initial_state": {
++ "dances": 0
++ },
++ "created_at": "2026-11-11T20:00:00.000Z",
++ "updated_at": "2026-11-11T20:30:00.000Z"
++}
ddiff --git a/fixtures/ui.element/valid/mod-wave.json b/fixtures/ui.element/valid/mod-wave.json
nnew file mode 100644
iindex 00000000..546b6fad
---- /dev/null
++++ b/fixtures/ui.element/valid/mod-wave.json
@@@ -0,0 +1,40 @@
++{
++ "name": "Wheel of chatters",
++ "author": {
++ "name": "Ada"
++ },
++ "sharing": "link",
++ "look": {
++ "component": "wheel",
++ "label": "Spin the wheel"
++ },
++ "recipe": {
++ "triggers": [
++ {
++ "kind": "press"
++ },
++ {
++ "kind": "chat_command",
++ "command": "!wheel"
++ }
++ ],
++ "actions": [
++ {
++ "kind": "robot.say",
++ "product": "bot",
++ "text": "The wheel says hello."
++ }
++ ]
++ },
++ "capabilities": [
++ "robot.say"
++ ],
++ "mod": {
++ "id": "mod_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "version": "1.2.0",
++ "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
++ "capabilities": [
++ "robot.say"
++ ]
++ }
++}
ddiff --git a/fixtures/ui.layout-request/invalid/empty-words.json b/fixtures/ui.layout-request/invalid/empty-words.json
nnew file mode 100644
iindex 00000000..886ff26e
---- /dev/null
++++ b/fixtures/ui.layout-request/invalid/empty-words.json
@@@ -0,0 +1,22 @@
++{
++ "catalog": {
++ "id": "bot.controls",
++ "version": 1,
++ "name": "Robot controls",
++ "devices": ["phone"],
++ "grid": { "columns": { "phone": 4, "tablet": 8, "computer": 12 }, "cell_px": { "phone": 44, "tablet": 44, "computer": 44 } },
++ "theme_tokens": ["danger"],
++ "parts": [
++ {
++ "id": "stop", "name": "Stop", "kind": "button",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2 },
++ "styles": { "tints": ["danger"], "shapes": ["round"] },
++ "bindings": { "keys": ["Space"], "gamepad": ["south"] }
++ }
++ ]
++ },
++ "layout": null,
++ "words": "",
++ "device": "phone"
++}
ddiff --git a/fixtures/ui.layout-request/valid/no-layout.json b/fixtures/ui.layout-request/valid/no-layout.json
nnew file mode 100644
iindex 00000000..4359c36a
---- /dev/null
++++ b/fixtures/ui.layout-request/valid/no-layout.json
@@@ -0,0 +1,22 @@
++{
++ "catalog": {
++ "id": "bot.controls",
++ "version": 1,
++ "name": "Robot controls",
++ "devices": ["phone"],
++ "grid": { "columns": { "phone": 4, "tablet": 8, "computer": 12 }, "cell_px": { "phone": 44, "tablet": 44, "computer": 44 } },
++ "theme_tokens": ["danger"],
++ "parts": [
++ {
++ "id": "stop", "name": "Stop", "kind": "button",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2 },
++ "styles": { "tints": ["danger"], "shapes": ["round"] },
++ "bindings": { "keys": ["Space"], "gamepad": ["south"] }
++ }
++ ]
++ },
++ "layout": null,
++ "words": "make it work with one thumb",
++ "device": "phone"
++}
ddiff --git a/fixtures/ui.layout-request/valid/phone.json b/fixtures/ui.layout-request/valid/phone.json
nnew file mode 100644
iindex 00000000..f1ebae15
---- /dev/null
++++ b/fixtures/ui.layout-request/valid/phone.json
@@@ -0,0 +1,40 @@
++{
++ "catalog": {
++ "id": "bot.controls",
++ "version": 1,
++ "name": "Robot controls",
++ "devices": ["phone"],
++ "grid": { "columns": { "phone": 4, "tablet": 8, "computer": 12 }, "cell_px": { "phone": 44, "tablet": 44, "computer": 44 } },
++ "theme_tokens": ["danger", "surface"],
++ "parts": [
++ {
++ "id": "stop", "name": "Stop", "kind": "button",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2, "px": 64 },
++ "styles": { "tints": ["danger"], "shapes": ["round"] },
++ "bindings": { "keys": ["Space"], "gamepad": ["south"] }
++ },
++ {
++ "id": "stick", "name": "Drive stick", "kind": "stick",
++ "required": false, "pressed": true,
++ "min": { "w": 3, "h": 3 },
++ "styles": { "tints": ["surface"], "shapes": ["round"] },
++ "bindings": { "keys": ["W", "A", "S", "D"], "gamepad": ["left-stick"] }
++ }
++ ]
++ },
++ "layout": {
++ "surface": "bot.controls",
++ "surface_version": 1,
++ "device": "phone",
++ "name": "Default",
++ "author": { "name": "OpenVibe" },
++ "sharing": "private",
++ "parts": [
++ { "id": "stop", "visible": true, "place": { "x": 0, "y": 0, "w": 2, "h": 2 }, "style": { "tint": "danger", "shape": "round", "opacity": 1, "label": true }, "bindings": { "keys": ["Space"], "gamepad": ["south"] } },
++ { "id": "stick", "visible": true, "place": { "x": 0, "y": 2, "w": 4, "h": 3 }, "style": { "tint": "surface", "shape": "round", "opacity": 1, "label": false }, "bindings": { "keys": ["W", "A", "S", "D"], "gamepad": ["left-stick"] } }
++ ]
++ },
++ "words": "put Stop on the left and make it huge, and give me a vertical head slider on the right edge",
++ "device": "phone"
++}
ddiff --git a/fixtures/ui.layout-suggestion/invalid/ok-false-with-layout.json b/fixtures/ui.layout-suggestion/invalid/ok-false-with-layout.json
nnew file mode 100644
iindex 00000000..3a431289
---- /dev/null
++++ b/fixtures/ui.layout-suggestion/invalid/ok-false-with-layout.json
@@@ -0,0 +1,16 @@
++{
++ "ok": false,
++ "layout": {
++ "surface": "bot.controls",
++ "surface_version": 1,
++ "device": "phone",
++ "name": "Still here",
++ "author": { "name": "Ana" },
++ "sharing": "private",
++ "parts": [
++ { "id": "stop", "visible": true, "place": { "x": 0, "y": 0, "w": 2, "h": 2 }, "style": { "tint": "danger", "shape": "round", "opacity": 1, "label": true }, "bindings": { "keys": [], "gamepad": [] } }
++ ]
++ },
++ "summary": "Couldn't do that.",
++ "reasons": ["Stop has to stay visible."]
++}
ddiff --git a/fixtures/ui.layout-suggestion/invalid/ok-true-without-layout.json b/fixtures/ui.layout-suggestion/invalid/ok-true-without-layout.json
nnew file mode 100644
iindex 00000000..55cb54a0
---- /dev/null
++++ b/fixtures/ui.layout-suggestion/invalid/ok-true-without-layout.json
@@@ -0,0 +1,6 @@
++{
++ "ok": true,
++ "layout": null,
++ "summary": "Put Stop on the left.",
++ "reasons": []
++}
ddiff --git a/fixtures/ui.layout-suggestion/valid/couldnt.json b/fixtures/ui.layout-suggestion/valid/couldnt.json
nnew file mode 100644
iindex 00000000..6ed8f504
---- /dev/null
++++ b/fixtures/ui.layout-suggestion/valid/couldnt.json
@@@ -0,0 +1,7 @@
++{
++ "ok": false,
++ "layout": null,
++ "summary": "Couldn't do that: Stop has to stay visible.",
++ "reasons": ["Stop has to stay visible.", "Every pressed part has to be at least 44 px."],
++ "receipt": { "model": "deepseek-flash", "provider": "deepseek", "route": "ui.layout.suggest", "run_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPR" }
++}
ddiff --git a/fixtures/ui.layout-suggestion/valid/suggestion.json b/fixtures/ui.layout-suggestion/valid/suggestion.json
nnew file mode 100644
iindex 00000000..4d874d05
---- /dev/null
++++ b/fixtures/ui.layout-suggestion/valid/suggestion.json
@@@ -0,0 +1,18 @@
++{
++ "ok": true,
++ "layout": {
++ "surface": "bot.controls",
++ "surface_version": 1,
++ "device": "phone",
++ "name": "One-thumb controls",
++ "author": { "name": "Ana" },
++ "sharing": "private",
++ "parts": [
++ { "id": "stop", "visible": true, "place": { "x": 0, "y": 0, "w": 4, "h": 2 }, "style": { "tint": "danger", "shape": "round", "opacity": 1, "label": true }, "bindings": { "keys": ["Space"], "gamepad": ["south"] } },
++ { "id": "stick", "visible": true, "place": { "x": 0, "y": 2, "w": 4, "h": 3 }, "style": { "tint": "surface", "shape": "round", "opacity": 1, "label": false }, "bindings": { "keys": ["W", "A", "S", "D"], "gamepad": ["left-stick"] } }
++ ]
++ },
++ "summary": "Put Stop across the top and the drive stick below it where a thumb reaches.",
++ "reasons": ["Stop had to stay visible, so it became the top row.", "The stick moved under the thumb."],
++ "receipt": { "model": "deepseek-flash", "provider": "deepseek", "route": "ui.layout.suggest", "run_id": "run_01JAB2C3D4E5F6G7H8J9K0MNPQ", "cost_usd": 0.0004 }
++}
ddiff --git a/fixtures/ui.layout/invalid/colour-tint.json b/fixtures/ui.layout/invalid/colour-tint.json
nnew file mode 100644
iindex 00000000..c3bd7c7c
---- /dev/null
++++ b/fixtures/ui.layout/invalid/colour-tint.json
@@@ -0,0 +1,11 @@
++{
++ "surface": "bot.controls",
++ "surface_version": 1,
++ "device": "phone",
++ "name": "Bad tint",
++ "author": { "name": "Ana" },
++ "sharing": "private",
++ "parts": [
++ { "id": "stop", "visible": true, "place": { "x": 0, "y": 0, "w": 2, "h": 2 }, "style": { "tint": "#ff0000", "shape": "round", "opacity": 1, "label": true }, "bindings": { "keys": [], "gamepad": [] } }
++ ]
++}
ddiff --git a/fixtures/ui.layout/invalid/transparent.json b/fixtures/ui.layout/invalid/transparent.json
nnew file mode 100644
iindex 00000000..7483076b
---- /dev/null
++++ b/fixtures/ui.layout/invalid/transparent.json
@@@ -0,0 +1,11 @@
++{
++ "surface": "bot.controls",
++ "surface_version": 1,
++ "device": "phone",
++ "name": "Too faint",
++ "author": { "name": "Ana" },
++ "sharing": "private",
++ "parts": [
++ { "id": "stop", "visible": true, "place": { "x": 0, "y": 0, "w": 2, "h": 2 }, "style": { "tint": "danger", "shape": "round", "opacity": 0.2, "label": true }, "bindings": { "keys": [], "gamepad": [] } }
++ ]
++}
ddiff --git a/fixtures/ui.layout/valid/overlay-dock.json b/fixtures/ui.layout/valid/overlay-dock.json
nnew file mode 100644
iindex 00000000..66cd4651
---- /dev/null
++++ b/fixtures/ui.layout/valid/overlay-dock.json
@@@ -0,0 +1,56 @@
++{
++ "surface": "bot.overlay",
++ "surface_version": 1,
++ "device": "computer",
++ "name": "One-thumb overlay",
++ "author": {
++ "name": "Ana"
++ },
++ "sharing": "link",
++ "parts": [
++ {
++ "id": "stop",
++ "visible": true,
++ "place": {
++ "dock": "right",
++ "order": 0,
++ "w": 2,
++ "h": 1
++ },
++ "style": {
++ "tint": "danger",
++ "shape": "round",
++ "opacity": 1,
++ "label": true
++ },
++ "bindings": {
++ "keys": [
++ "Space"
++ ],
++ "gamepad": [
++ "south"
++ ]
++ }
++ },
++ {
++ "id": "battery",
++ "visible": true,
++ "place": {
++ "dock": "right",
++ "order": 1,
++ "w": 2,
++ "h": 1
++ },
++ "style": {
++ "tint": "surface",
++ "shape": "pill",
++ "opacity": 0.8,
++ "label": false
++ },
++ "bindings": {
++ "keys": [],
++ "gamepad": []
++ }
++ }
++ ]
++}
ddiff --git a/fixtures/ui.layout/valid/phone-controls.json b/fixtures/ui.layout/valid/phone-controls.json
nnew file mode 100644
iindex 00000000..b0cb9abb
---- /dev/null
++++ b/fixtures/ui.layout/valid/phone-controls.json
@@@ -0,0 +1,16 @@
++{
++ "surface": "bot.controls",
++ "surface_version": 1,
++ "device": "phone",
++ "name": "My phone controls",
++ "author": { "name": "Ana", "subject": { "type": "user", "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ" } },
++ "sharing": "private",
++ "parts": [
++ { "id": "stop", "visible": true, "place": { "x": 0, "y": 0, "w": 2, "h": 2 }, "style": { "tint": "danger", "shape": "round", "opacity": 1, "label": true }, "bindings": { "keys": ["Space"], "gamepad": ["south"] } },
++ { "id": "estop", "visible": true, "place": { "x": 2, "y": 0, "w": 2, "h": 2 }, "style": { "tint": "danger", "shape": "square", "opacity": 1, "label": true }, "bindings": { "keys": ["Escape"], "gamepad": ["start"] } },
++ { "id": "stick", "visible": true, "place": { "x": 0, "y": 2, "w": 4, "h": 3 }, "style": { "tint": "surface", "shape": "round", "opacity": 1, "label": false }, "bindings": { "keys": ["W", "A", "S", "D"], "gamepad": ["left-stick"] } },
++ { "id": "battery", "visible": true, "place": { "x": 0, "y": 5, "w": 2, "h": 1 }, "style": { "tint": "success", "shape": "pill", "opacity": 1, "label": true }, "bindings": { "keys": [], "gamepad": [] } },
++ { "id": "latency", "visible": false, "place": { "x": 2, "y": 5, "w": 2, "h": 1 }, "style": { "tint": "muted", "shape": "pill", "opacity": 1, "label": true }, "bindings": { "keys": [], "gamepad": [] } }
++ ],
++ "updated_at": "2026-11-11T12:00:00.000Z"
++}
ddiff --git a/fixtures/ui.surface/invalid/colour-tint.json b/fixtures/ui.surface/invalid/colour-tint.json
nnew file mode 100644
iindex 00000000..a5bfe210
---- /dev/null
++++ b/fixtures/ui.surface/invalid/colour-tint.json
@@@ -0,0 +1,17 @@
++{
++ "id": "bot.controls",
++ "version": 1,
++ "name": "Robot controls",
++ "devices": ["phone"],
++ "grid": { "columns": { "phone": 4, "tablet": 8, "computer": 12 }, "cell_px": { "phone": 44, "tablet": 44, "computer": 44 } },
++ "theme_tokens": ["danger"],
++ "parts": [
++ {
++ "id": "stop", "name": "Stop", "kind": "button",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2 },
++ "styles": { "tints": ["#ff0000"], "shapes": ["round"] },
++ "bindings": { "keys": ["Space"], "gamepad": ["south"] }
++ }
++ ]
++}
ddiff --git a/fixtures/ui.surface/invalid/unknown-kind.json b/fixtures/ui.surface/invalid/unknown-kind.json
nnew file mode 100644
iindex 00000000..6af9ba2d
---- /dev/null
++++ b/fixtures/ui.surface/invalid/unknown-kind.json
@@@ -0,0 +1,17 @@
++{
++ "id": "bot.controls",
++ "version": 1,
++ "name": "Robot controls",
++ "devices": ["phone"],
++ "grid": { "columns": { "phone": 4, "tablet": 8, "computer": 12 }, "cell_px": { "phone": 44, "tablet": 44, "computer": 44 } },
++ "theme_tokens": ["accent"],
++ "parts": [
++ {
++ "id": "stop", "name": "Stop", "kind": "power",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2 },
++ "styles": { "tints": ["danger"], "shapes": ["round"] },
++ "bindings": { "keys": ["Space"], "gamepad": ["south"] }
++ }
++ ]
++}
ddiff --git a/fixtures/ui.surface/valid/bot-controls.json b/fixtures/ui.surface/valid/bot-controls.json
nnew file mode 100644
iindex 00000000..07c4b554
---- /dev/null
++++ b/fixtures/ui.surface/valid/bot-controls.json
@@@ -0,0 +1,64 @@
++{
++ "id": "bot.controls",
++ "version": 1,
++ "name": "Robot controls",
++ "description": "The robot's own panel: Stop and the e-stop always visible, then the stick, the head slider and the readouts.",
++ "devices": ["phone", "tablet", "computer"],
++ "grid": {
++ "columns": { "phone": 4, "tablet": 8, "computer": 12 },
++ "cell_px": { "phone": 44, "tablet": 44, "computer": 44 }
++ },
++ "theme_tokens": ["accent", "surface", "surface-raised", "text", "muted", "danger", "warning", "success"],
++ "parts": [
++ {
++ "id": "stop", "name": "Stop", "kind": "button",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2, "px": 64 },
++ "sizes": [{ "w": 2, "h": 2 }, { "w": 3, "h": 2 }],
++ "styles": { "tints": ["danger", "warning"], "shapes": ["round", "pill", "square"], "opacity": { "min": 0.7, "max": 1 }, "label": true },
++ "bindings": { "keys": ["Space", "Escape"], "gamepad": ["south", "start"] },
++ "never_covers": []
++ },
++ {
++ "id": "estop", "name": "E-stop", "kind": "button",
++ "required": true, "pressed": true,
++ "min": { "w": 2, "h": 2, "px": 64 },
++ "styles": { "tints": ["danger"], "shapes": ["round", "square"], "opacity": { "min": 0.8, "max": 1 }, "label": true },
++ "bindings": { "keys": ["Escape"], "gamepad": ["start"] },
++ "never_covers": []
++ },
++ {
++ "id": "stick", "name": "Drive stick", "kind": "stick",
++ "required": false, "pressed": true,
++ "min": { "w": 3, "h": 3 },
++ "sizes": [{ "w": 3, "h": 3 }, { "w": 4, "h": 4 }],
++ "styles": { "tints": ["surface", "surface-raised"], "shapes": ["round"], "opacity": { "min": 0.6, "max": 1 }, "label": false },
++ "bindings": { "keys": ["W", "A", "S", "D"], "gamepad": ["left-stick"] },
++ "never_covers": ["stop", "estop"]
++ },
++ {
++ "id": "head", "name": "Head", "kind": "slider",
++ "required": false, "pressed": true,
++ "min": { "w": 2, "h": 2 },
++ "styles": { "tints": ["accent", "surface-raised"], "shapes": ["pill", "square"], "opacity": { "min": 0.4, "max": 1 }, "label": true },
++ "bindings": { "keys": ["ArrowUp", "ArrowDown"], "gamepad": ["right-stick-y"] },
++ "never_covers": ["stop", "estop"]
++ },
++ {
++ "id": "battery", "name": "Battery", "kind": "readout",
++ "required": false, "pressed": false,
++ "min": { "w": 1, "h": 1 },
++ "styles": { "tints": ["text", "muted", "success"], "shapes": ["pill", "square"], "opacity": { "min": 0.4, "max": 1 }, "label": true },
++ "bindings": { "keys": [], "gamepad": [] },
++ "never_covers": ["stop", "estop"]
++ },
++ {
++ "id": "latency", "name": "Latency", "kind": "readout",
++ "required": false, "pressed": false,
++ "min": { "w": 1, "h": 1 },
++ "styles": { "tints": ["text", "muted"], "shapes": ["pill", "square"], "opacity": { "min": 0.4, "max": 1 }, "label": true },
++ "bindings": { "keys": [], "gamepad": [] },
++ "never_covers": ["stop", "estop"]
++ }
++ ]
++}
ddiff --git a/fixtures/ui.trigger/invalid/command-without-bang.json b/fixtures/ui.trigger/invalid/command-without-bang.json
nnew file mode 100644
iindex 00000000..0c70c970
---- /dev/null
++++ b/fixtures/ui.trigger/invalid/command-without-bang.json
@@@ -0,0 +1,4 @@
++{
++ "kind": "chat_command",
++ "command": "dance"
++}
ddiff --git a/fixtures/ui.trigger/invalid/hold-too-short.json b/fixtures/ui.trigger/invalid/hold-too-short.json
nnew file mode 100644
iindex 00000000..2c53a7b0
---- /dev/null
++++ b/fixtures/ui.trigger/invalid/hold-too-short.json
@@@ -0,0 +1,4 @@
++{
++ "kind": "hold",
++ "ms": 10
++}
ddiff --git a/fixtures/ui.trigger/invalid/unknown-kind.json b/fixtures/ui.trigger/invalid/unknown-kind.json
nnew file mode 100644
iindex 00000000..5103d47f
---- /dev/null
++++ b/fixtures/ui.trigger/invalid/unknown-kind.json
@@@ -0,0 +1,3 @@
++{
++ "kind": "cheer"
++}
ddiff --git a/fixtures/ui.trigger/valid/chat-command.json b/fixtures/ui.trigger/valid/chat-command.json
nnew file mode 100644
iindex 00000000..ff0cbc61
---- /dev/null
++++ b/fixtures/ui.trigger/valid/chat-command.json
@@@ -0,0 +1,4 @@
++{
++ "kind": "chat_command",
++ "command": "!dance"
++}
ddiff --git a/fixtures/ui.trigger/valid/press.json b/fixtures/ui.trigger/valid/press.json
nnew file mode 100644
iindex 00000000..2e94ff0d
---- /dev/null
++++ b/fixtures/ui.trigger/valid/press.json
@@@ -0,0 +1,3 @@
++{
++ "kind": "press"
++}
ddiff --git a/fixtures/ui.trigger/valid/robot-battery.json b/fixtures/ui.trigger/valid/robot-battery.json
nnew file mode 100644
iindex 00000000..10fbd3fe
---- /dev/null
++++ b/fixtures/ui.trigger/valid/robot-battery.json
@@@ -0,0 +1,7 @@
++{
++ "kind": "robot_telemetry",
++ "metric": "battery_percent",
++ "op": "lt",
++ "value": 20,
++ "for_seconds": 30
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex fe723f3f..1ab7ffac 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -130805,6 +130805,3579 @@
}
}
}
++ },
++ "ui.surface": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/surface.v1.json",
++ "title": "UiSurface",
++ "description": "ui.surface@1: the catalog a product publishes for one surface people may arrange (bot.controls, bot.overlay, live.channel, dock, chat.composer, navbar.shortcuts). It names the surface, its device classes and grid, the theme tokens its styles draw from, and every part a layout may place. A part says what it is (stick, button, slider, readout, video, list), whether it is required or pressed, its minimum cells and the minimum pixels the product guarantees (44 px for anything pressed, larger for Stop), the sizes and styles it allows, the bindings it accepts, and the parts it must never cover. A layout is validated against this catalog (openvibe-sdk/ui-layout); a layout can arrange only what the catalog has and can never add a power. The catalog is data: a layout or an AI answer never carries code or a URL.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "version",
++ "name",
++ "devices",
++ "grid",
++ "theme_tokens",
++ "parts"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80,
++ "description": "The surface id: <product>.<surface>, e.g. bot.controls."
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The catalog version. A layout names the version it was made against."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "What the surface is called where a person sees it."
++ },
++ "description": {
++ "type": "string",
++ "maxLength": 500
++ },
++ "devices": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 3,
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "description": "The device classes this surface offers."
++ },
++ "grid": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "columns",
++ "cell_px"
++ ],
++ "properties": {
++ "columns": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "phone",
++ "tablet",
++ "computer"
++ ],
++ "description": "The grid's columns per device class: 4 on a phone and 12 on a computer in the design; a product may choose otherwise within these bounds.",
++ "properties": {
++ "phone": {
++ "type": "integer",
++ "minimum": 2,
++ "maximum": 8
++ },
++ "tablet": {
++ "type": "integer",
++ "minimum": 2,
++ "maximum": 16
++ },
++ "computer": {
++ "type": "integer",
++ "minimum": 2,
++ "maximum": 24
++ }
++ }
++ },
++ "cell_px": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "phone",
++ "tablet",
++ "computer"
++ ],
++ "description": "The smallest a cell is rendered at that device class, in CSS pixels, at the narrowest supported width. The validator multiplies a part's cells by this to check the 44 px rule.",
++ "properties": {
++ "phone": {
++ "type": "integer",
++ "minimum": 24,
++ "maximum": 200
++ },
++ "tablet": {
++ "type": "integer",
++ "minimum": 24,
++ "maximum": 200
++ },
++ "computer": {
++ "type": "integer",
++ "minimum": 24,
++ "maximum": 200
++ }
++ }
++ }
++ }
++ },
++ "theme_tokens": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "description": "The theme tokens a tint may name (accent, surface, danger, ...). Never a colour value: the theme decides what the token is."
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": {
++ "$ref": "#/$defs/part"
++ }
++ }
++ },
++ "$defs": {
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "name",
++ "kind",
++ "required",
++ "pressed",
++ "min",
++ "styles",
++ "bindings"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$",
++ "description": "Stable part id inside this catalog, e.g. stop, stick, battery."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "kind": {
++ "enum": [
++ "stick",
++ "button",
++ "slider",
++ "readout",
++ "video",
++ "list"
++ ]
++ },
++ "required": {
++ "type": "boolean",
++ "description": "A required part is always visible and at its minimum size (Stop and the e-stop). A layout that hides or shrinks it is refused."
++ },
++ "pressed": {
++ "type": "boolean",
++ "description": "The part sends a command when pressed or moved; it must render at least 44 px on every side."
++ },
++ "min": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "w",
++ "h"
++ ],
++ "description": "The smallest size a layout may give the part: w and h in grid cells, and px when the product guarantees more than 44 px (Stop).",
++ "properties": {
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "px": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 400
++ }
++ }
++ },
++ "sizes": {
++ "type": "array",
++ "maxItems": 12,
++ "uniqueItems": true,
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "w",
++ "h"
++ ],
++ "properties": {
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ }
++ },
++ "description": "Sizes the editor may offer as presets. A layout is not limited to these: it is limited to min and to the grid."
++ },
++ "styles": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "tints",
++ "shapes"
++ ],
++ "properties": {
++ "tints": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "description": "The theme tokens this part may be tinted with; every one is also in the surface's theme_tokens."
++ },
++ "shapes": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 3,
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ }
++ },
++ "opacity": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "min",
++ "max"
++ ],
++ "properties": {
++ "min": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "max": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ }
++ },
++ "description": "The opacity range this part allows; the design's range is 0.4 to 1."
++ },
++ "label": {
++ "type": "boolean",
++ "description": "Whether the editor may show the part's label on it."
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "keys",
++ "gamepad"
++ ],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ }
++ }
++ },
++ "description": "The bindings the product accepts for this part. A layout may pick a subset, never add one."
++ },
++ "never_covers": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "description": "The parts this part must never cover, in addition to the surface-wide rule that nothing covers a required part."
++ }
++ }
++ }
++ }
++ }
++ },
++ "ui.layout": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/layout.v1.json",
++ "title": "UiLayout",
++ "description": "ui.layout@1: one person's arrangement of one surface on one device class. A layout names the surface and its catalog version, the device, its own name, its author and its sharing (private, link or gallery), and one entry per part it places. A part is visible or not, sits on the surface's grid (x, y, w, h in cells) or docks to an edge for an overlay, and carries a style (a theme token tint, a shape, opacity 0.4-1 and the label) and bindings. It is data only: no URL, no markup and no code may appear anywhere in it, and every part id, tint, shape, opacity, size and binding must be one the catalog allows. openvibe-sdk/ui-layout validates it before any server, editor or AI answer stores or shows it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "surface",
++ "surface_version",
++ "device",
++ "name",
++ "author",
++ "sharing",
++ "parts"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^lay_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Assigned by OpenVibe.Network when the layout is stored; absent in a draft, a share answer or an AI suggestion."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Assigned by the store; every change to a stored layout moves it by one."
++ },
++ "active": {
++ "type": "boolean",
++ "description": "Set by OpenVibe.Network: the one layout a product draws for this surface on this device class. A draft, a share answer and an AI suggestion carry no active field."
++ },
++ "surface": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80
++ },
++ "surface_version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "The person's name for this layout; \"My phone controls\" is fine."
++ },
++ "author": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "subject": {
++ "$ref": "../identity/subject-ref.v1.json"
++ }
++ },
++ "description": "The author's display name and, for a private or link layout, the subject. A share or gallery answer carries the display name only, never the account."
++ },
++ "sharing": {
++ "enum": [
++ "private",
++ "link",
++ "gallery"
++ ]
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": {
++ "$ref": "#/$defs/part"
++ }
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "place": {
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "x",
++ "y",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "x": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 23
++ },
++ "y": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "The part's cell on the surface grid."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "dock",
++ "order",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "dock": {
++ "enum": [
++ "left",
++ "right",
++ "top",
++ "bottom"
++ ]
++ },
++ "order": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 63
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "For an overlay: the edge the part docks to, its order along that edge, and its size in cells."
++ }
++ ]
++ },
++ "style": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "tint",
++ "shape",
++ "opacity",
++ "label"
++ ],
++ "properties": {
++ "tint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "A theme token, never a colour: the theme decides what it is, so contrast follows the theme."
++ },
++ "shape": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ },
++ "opacity": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "label": {
++ "type": "boolean"
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "keys",
++ "gamepad"
++ ],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ }
++ }
++ }
++ },
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "visible",
++ "place",
++ "style",
++ "bindings"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "visible": {
++ "type": "boolean"
++ },
++ "place": {
++ "$ref": "#/$defs/place"
++ },
++ "style": {
++ "$ref": "#/$defs/style"
++ },
++ "bindings": {
++ "$ref": "#/$defs/bindings"
++ }
++ }
++ }
++ }
++ }
++ },
++ "ui.layout-request": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/layout-request.v1.json",
++ "title": "UiLayoutRequest",
++ "description": "ui.layout-request@1: what an editor sends OpenVibe.AI's ui.layout.suggest task, and what Network forwards for a signed-in person. It is the surface's catalog, the layout shown now (or null for the default one) and the words the person typed (max 500 characters), for one device class. The catalog is the only power the answer can use; the current layout is the starting arrangement; the words never contain markup that becomes part of it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "catalog",
++ "layout",
++ "words",
++ "device"
++ ],
++ "properties": {
++ "catalog": {
++ "$ref": "surface.v1.json"
++ },
++ "layout": {
++ "oneOf": [
++ {
++ "$ref": "layout.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The arrangement shown now, or null when the person is looking at the default."
++ },
++ "words": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500,
++ "description": "The person's own description, e.g. \"put Stop on the left and make it huge\"."
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ }
++ }
++ }
++ },
++ "ui.layout-suggestion": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/layout-suggestion.v1.json",
++ "title": "UiLayoutSuggestion",
++ "description": "ui.layout-suggestion@1: OpenVibe.AI's answer to ui.layout.suggest. ok true carries the layout, already checked by openvibe-sdk/ui-layout against the catalog sent with the request, and a one-sentence summary of what changed plus the model's reasons. ok false is an honest refusal: no layout, one sentence saying what it could not do, and the validator's reasons in plain words (for example \"Stop has to stay visible.\"). A receipt names the model that answered, with the run and the route. The suggestion is previewed, never applied silently; Network or the product stores it only after the person accepts it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "ok",
++ "layout",
++ "summary",
++ "reasons"
++ ],
++ "properties": {
++ "ok": {
++ "type": "boolean"
++ },
++ "layout": {
++ "oneOf": [
++ {
++ "$ref": "layout.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The validated layout when ok is true; null when it is false."
++ },
++ "summary": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300,
++ "description": "One sentence saying what changed, or what could not be done."
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300
++ },
++ "description": "Why this arrangement, or the validator's reasons for refusing one."
++ },
++ "receipt": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "model"
++ ],
++ "properties": {
++ "model": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "provider": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 100
++ },
++ "route": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "run_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ }
++ },
++ "description": "Which model answered: the same receipt every AI run carries, at the task's own shape."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "ok": {
++ "const": true
++ }
++ },
++ "required": [
++ "ok"
++ ]
++ },
++ "then": {
++ "properties": {
++ "layout": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "ok": {
++ "const": false
++ }
++ },
++ "required": [
++ "ok"
++ ]
++ },
++ "then": {
++ "properties": {
++ "layout": {
++ "type": "null"
++ }
++ }
++ }
++ }
++ ]
++ }
++ },
++ "network.layout.changed": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.layout.changed.v1.json",
++ "title": "NetworkLayoutChangedPayload",
++ "description": "network.layout.changed v1 (OpenVibe.Network server/layouts, written into network_event_outbox in the transaction that changes a stored layout). One event per change to one person's ui.layout@1: set active, saved, renamed, deleted, shared, unshared, published to the gallery, unpublished, copied from the gallery, or reset to the default. Envelope: subject { type: user, id } and the layout's revision, actor the person, visibility internal. The payload carries no account data and no layout contents, so an open page can announce a change and refetch with the person's own token.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "surface",
++ "device",
++ "layout_id",
++ "change",
++ "revision"
++ ],
++ "properties": {
++ "surface": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "layout_id": {
++ "type": "string",
++ "pattern": "^lay_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "change": {
++ "enum": [
++ "active",
++ "saved",
++ "renamed",
++ "deleted",
++ "shared",
++ "unshared",
++ "published",
++ "unpublished",
++ "copied",
++ "reset"
++ ]
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "sharing": {
++ "enum": [
++ "private",
++ "link",
++ "gallery"
++ ]
++ }
++ }
++ }
++ },
++ "ui.action": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/action.v1.json",
++ "title": "UiAction",
++ "description": "ui.action@1: one action an Element's recipe may request, from the fixed catalog, with the product that authorises it. An element never has a power of its own: the host asks the product named here, and that product checks the presser's role, the element's declared capability, its cooldown and cost, and its own owner limits before it does anything. kind is also the capability id an element declares in ui.element@1, and product is fixed by kind: bot for robot.drive_pulse, robot.head_preset, robot.headlight, robot.say and robot.macro; chat for chat.message, chat.pin and chat.poll; live for the screen, stream and webhook actions; ai for ai.ask; actor for actor.task; billing for money.tip_goal. A robot action is a request through Bot's existing command path: turns, Stop, the e-stop and owner takeover are never bypassed. Money never moves from an action without Billing's own confirmation.",
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "direction",
++ "ms"
++ ],
++ "description": "Drive the robot for a moment, then stop. The duration is bounded by Bot's own max_command_ms and the owner's limits for the presser's role.",
++ "properties": {
++ "kind": {
++ "const": "robot.drive_pulse"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "direction": {
++ "enum": [
++ "forward",
++ "back",
++ "left",
++ "right",
++ "spin_left",
++ "spin_right"
++ ]
++ },
++ "ms": {
++ "type": "integer",
++ "minimum": 50,
++ "maximum": 3000
++ },
++ "speed": {
++ "type": "number",
++ "minimum": 0.1,
++ "maximum": 1,
++ "description": "Fraction of the robot's allowed speed; omitted means the owner's default for the role."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "preset"
++ ],
++ "description": "Take the robot's head to a preset. Bot maps up, center and down to the head actuator its robot profile declares; a robot without one refuses it.",
++ "properties": {
++ "kind": {
++ "const": "robot.head_preset"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "preset": {
++ "enum": [
++ "up",
++ "center",
++ "down"
++ ],
++ "description": "A head tilt preset. Bot still checks the robot profile and the owner's limits."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "on"
++ ],
++ "description": "Turn the robot's headlight on or off. Bot's own headlight kind is the authority: only the owner and People Operators may send it, and a robot whose profile has no headlight refuses it.",
++ "properties": {
++ "kind": {
++ "const": "robot.headlight"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "on": {
++ "type": "boolean"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "text"
++ ],
++ "description": "Speak a line through the robot. Text goes through the existing content checks and Bot's own speak limits.",
++ "properties": {
++ "kind": {
++ "const": "robot.say"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "macro"
++ ],
++ "description": "Replay a recorded robot macro through the safe command path, with its own deadline. Macros are stage 4 of Elements; a stage-1 product refuses this kind as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "robot.macro"
++ },
++ "product": {
++ "const": "bot"
++ },
++ "macro": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "text"
++ ],
++ "description": "Send a chat message as the channel's bot. Text goes through Chat's content checks and rate limits.",
++ "properties": {
++ "kind": {
++ "const": "chat.message"
++ },
++ "product": {
++ "const": "chat"
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 400
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "source"
++ ],
++ "properties": {
++ "kind": {
++ "const": "chat.pin"
++ },
++ "product": {
++ "const": "chat"
++ },
++ "source": {
++ "enum": [
++ "trigger",
++ "latest"
++ ],
++ "description": "trigger pins the message that caused this action; latest pins the newest chat message."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "question",
++ "options"
++ ],
++ "properties": {
++ "kind": {
++ "const": "chat.poll"
++ },
++ "product": {
++ "const": "chat"
++ },
++ "question": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "options": {
++ "type": "array",
++ "minItems": 2,
++ "maxItems": 10,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "title"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.alert"
++ },
++ "product": {
++ "const": "live"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "name"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.animation"
++ },
++ "product": {
++ "const": "live"
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "An animation the page or overlay has; an unknown name is refused, never fetched."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.confetti"
++ },
++ "product": {
++ "const": "live"
++ },
++ "intensity": {
++ "enum": [
++ "low",
++ "medium",
++ "high"
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "sound"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.sound"
++ },
++ "product": {
++ "const": "live"
++ },
++ "sound": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "A sound from the streamer's own sound list; Live never fetches a URL for it."
++ },
++ "volume": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "text",
++ "voice"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.tts"
++ },
++ "product": {
++ "const": "live"
++ },
++ "text": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300
++ },
++ "voice": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "A voice from the streamer's own voice list."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "key",
++ "by"
++ ],
++ "description": "Move one of the element's counters. The host applies it to the element's own state and Live broadcasts the new state.",
++ "properties": {
++ "kind": {
++ "const": "screen.counter"
++ },
++ "product": {
++ "const": "live"
++ },
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{0,31}$"
++ },
++ "by": {
++ "type": "integer",
++ "minimum": -1000,
++ "maximum": 1000
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "key",
++ "value"
++ ],
++ "description": "Set one of the element's state values: a boolean, a number, a short string or a list of those (a wheel's entries, a scoreboard). Objects are not state values.",
++ "properties": {
++ "kind": {
++ "const": "screen.state"
++ },
++ "product": {
++ "const": "live"
++ },
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{0,31}$"
++ },
++ "value": {
++ "oneOf": [
++ {
++ "type": "boolean"
++ },
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ },
++ {
++ "type": "array",
++ "maxItems": 64,
++ "items": {
++ "oneOf": [
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ }
++ ]
++ }
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "element",
++ "visible"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.visibility"
++ },
++ "product": {
++ "const": "live"
++ },
++ "element": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "visible": {
++ "type": "boolean"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "scene"
++ ],
++ "properties": {
++ "kind": {
++ "const": "screen.scene"
++ },
++ "product": {
++ "const": "live"
++ },
++ "scene": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "A scene the layout has; an unknown scene is refused."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.clip"
++ },
++ "product": {
++ "const": "live"
++ },
++ "seconds": {
++ "type": "integer",
++ "minimum": 5,
++ "maximum": 30,
++ "description": "How far back the clip starts, at most 30 seconds."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.moment"
++ },
++ "product": {
++ "const": "live"
++ },
++ "label": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 120
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "title"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.title"
++ },
++ "product": {
++ "const": "live"
++ },
++ "title": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 140
++ }
++ },
++ "description": "Change the stream title. Live allows it only for the channel's owner, whatever the element's trigger says."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "category"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream.category"
++ },
++ "product": {
++ "const": "live"
++ },
++ "category": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ },
++ "description": "Change the stream category. Live allows it only for the channel's owner."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "prompt"
++ ],
++ "description": "Ask an AI model through Exchange's catalogue, with the element's budget and a receipt. Stage 2 of Elements; a stage-1 product refuses it as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "ai.ask"
++ },
++ "product": {
++ "const": "ai"
++ },
++ "prompt": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500
++ },
++ "model": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "max_cost_usd": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "task"
++ ],
++ "description": "Hand a task to an Actor agent. Stage 2 of Elements; a stage-1 product refuses it as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "actor.task"
++ },
++ "product": {
++ "const": "actor"
++ },
++ "task": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 8000
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "endpoint"
++ ],
++ "description": "Call one of the streamer's own webhook endpoints. The element names the endpoint the streamer listed; it never carries a URL, and the payload never carries a viewer's data. Stage 2 of Elements; a stage-1 product refuses it as not yet supported.",
++ "properties": {
++ "kind": {
++ "const": "webhook.call"
++ },
++ "product": {
++ "const": "live"
++ },
++ "endpoint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]{0,31}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "product",
++ "goal"
++ ],
++ "description": "Update a tip goal's display. Money never moves here; Billing's own sheet confirms any charge. Stage 4 of Elements.",
++ "properties": {
++ "kind": {
++ "const": "money.tip_goal"
++ },
++ "product": {
++ "const": "billing"
++ },
++ "goal": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40
++ }
++ }
++ }
++ ],
++ "$defs": {
++ "capability": {
++ "description": "The action kinds an Element may declare in ui.element@1 capabilities. One id, one product: the host routes an action only to the product that owns it.",
++ "enum": [
++ "robot.drive_pulse",
++ "robot.head_preset",
++ "robot.headlight",
++ "robot.say",
++ "robot.macro",
++ "chat.message",
++ "chat.pin",
++ "chat.poll",
++ "screen.alert",
++ "screen.animation",
++ "screen.confetti",
++ "screen.sound",
++ "screen.tts",
++ "screen.counter",
++ "screen.state",
++ "screen.visibility",
++ "screen.scene",
++ "stream.clip",
++ "stream.moment",
++ "stream.title",
++ "stream.category",
++ "ai.ask",
++ "actor.task",
++ "webhook.call",
++ "money.tip_goal"
++ ]
++ }
++ }
++ }
++ },
++ "ui.element": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/element.v1.json",
++ "title": "UiElement",
++ "description": "ui.element@1: an Element people make and place on a surface: a look, a behaviour and, only when a recipe is not enough, a mod. LOOK draws from OpenVibe.Design's components and the theme's tokens, so it follows the person's theme, the seasons and dark/light; it carries no markup and no URL. RECIPE is data only: one or more triggers from ui.trigger@1, the conditions that decide who may fire it and what it costs, and one or more actions from ui.action@1, checked against CAPABILITIES. A capability is an action kind the element declares; the host refuses an action the element did not declare, and the product named by the action still checks the presser's role, the cooldown, the cost and its own limits. MOD is an optional reference to a code module (id, version and digest) for the stage where a recipe is not enough; the module never adds a power. Conditions use channel points only in stage 1: no Vibes, no money, and Billing still confirms any charge itself. The id and revision are assigned by the store; a draft has neither.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name",
++ "author",
++ "sharing",
++ "look",
++ "recipe",
++ "capabilities"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Assigned by the store when the element is saved; absent in a draft."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Assigned by the store; every saved change moves it by one."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "description": {
++ "type": "string",
++ "maxLength": 500
++ },
++ "author": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "subject": {
++ "$ref": "../identity/subject-ref.v1.json"
++ }
++ },
++ "description": "The author's display name and, for a private or link element, the subject. A gallery or copy answer carries the display name only, never the account."
++ },
++ "sharing": {
++ "enum": [
++ "private",
++ "link",
++ "gallery"
++ ]
++ },
++ "look": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "component",
++ "label"
++ ],
++ "properties": {
++ "component": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,63}$",
++ "description": "An OpenVibe.Design component name (design.component@1), e.g. button or meter. A name the page does not have is refused, never fetched."
++ },
++ "label": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40,
++ "description": "The words on the element, e.g. Make it dance."
++ },
++ "icon": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,31}$",
++ "description": "A glyph name from the design icon set."
++ },
++ "props": {
++ "type": "object",
++ "maxProperties": 20,
++ "propertyNames": {
++ "pattern": "^[a-z][a-zA-Z0-9]{0,31}$"
++ },
++ "additionalProperties": {
++ "oneOf": [
++ {
++ "type": "boolean"
++ },
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ }
++ ]
++ },
++ "description": "The design component's own props (the sliders the editor shows). Numbers, strings and booleans only; no nesting."
++ },
++ "style": {
++ "$ref": "#/$defs/style"
++ }
++ }
++ },
++ "recipe": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "triggers",
++ "actions"
++ ],
++ "properties": {
++ "triggers": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 8,
++ "items": {
++ "$ref": "trigger.v1.json"
++ }
++ },
++ "conditions": {
++ "$ref": "#/$defs/conditions"
++ },
++ "actions": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "items": {
++ "$ref": "action.v1.json"
++ }
++ }
++ }
++ },
++ "capabilities": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "$ref": "action.v1.json#/$defs/capability"
++ },
++ "description": "The action kinds this element may request. Every action in the recipe must be declared here; the host refuses the rest."
++ },
++ "initial_state": {
++ "$ref": "element-state.v1.json#/$defs/values",
++ "description": "The element's state before anything runs; the counter starts at 0, never at a value the element did not declare."
++ },
++ "mod": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "version",
++ "sha256"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"
++ },
++ "sha256": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "capabilities": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "$ref": "action.v1.json#/$defs/capability"
++ },
++ "description": "The subset of the element's capabilities the module itself may request; omitted means the element's declared capabilities, and a product may grant fewer."
++ }
++ },
++ "description": "A code module for an element whose recipe is not enough (stage 3). It runs sandboxed; the reference is data, and the host checks every request the module makes against the element's capabilities."
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "style": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "tint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "A theme token, never a colour: the theme decides what it is, so contrast follows the theme."
++ },
++ "shape": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ },
++ "opacity": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "show_label": {
++ "type": "boolean"
++ }
++ }
++ },
++ "conditions": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "role": {
++ "enum": [
++ "everyone",
++ "follower",
++ "subscriber",
++ "vip",
++ "operator",
++ "owner"
++ ],
++ "description": "Who may fire it. A higher role admits the lower ones where the product says so (an operator is admitted to subscriber-only); owner is the channel's owner alone. Default everyone."
++ },
++ "cost": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "points"
++ ],
++ "properties": {
++ "points": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 1000000
++ }
++ },
++ "description": "Channel points the presser spends, from Live's points. Stage 1 has no other currency, and an element never charges money."
++ },
++ "cooldown": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "per_person_seconds": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 86400
++ },
++ "overall_seconds": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 86400
++ }
++ },
++ "description": "How long the same person must wait, and how long everybody must wait, before it can fire again."
++ },
++ "daily_cap": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "per_person": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10000
++ },
++ "overall": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 1000000
++ }
++ },
++ "description": "The most times it may fire in a UTC day, per person and for everyone."
++ },
++ "live_only": {
++ "type": "boolean",
++ "description": "When true the element fires only while the channel is live."
++ },
++ "robot_turn_only": {
++ "type": "boolean",
++ "description": "When true a robot action is requested only while the presser has the robot's turn; Bot still decides."
++ },
++ "chance": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "one_in"
++ ],
++ "properties": {
++ "one_in": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 1000000
++ }
++ },
++ "description": "Fire only one time in N. The host draws the lot, so a refused draw costs nothing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "ui.element-state": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/element-state.v1.json",
++ "title": "UiElementState",
++ "description": "ui.element-state@1: the small store an Element keeps for itself (counters, scores, lists, a wheel's entries), as Live broadcasts it to everyone watching and as a read returns it. CHANNEL and ELEMENT_ID name the element instance, REVISION grows by one with each accepted change so a late frame never overwrites a newer one, and VALUES is the store itself: at most 32 keys, each a boolean, a number, a short string or a list of those. It is state, not code: no objects, no markup, no URL and no account data, which keeps a shared element's state safe to render for every viewer.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "channel",
++ "element_id",
++ "revision",
++ "values",
++ "updated_at"
++ ],
++ "properties": {
++ "channel": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",
++ "description": "The Live channel's username (its slug)."
++ },
++ "element_id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "values": {
++ "$ref": "#/$defs/values"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "values": {
++ "type": "object",
++ "maxProperties": 32,
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9_]{0,31}$"
++ },
++ "additionalProperties": {
++ "$ref": "#/$defs/value"
++ },
++ "description": "The element's own store. A key is lower-case and 32 characters at most; a value is a boolean, a number, a string of 200 characters at most or a list of those."
++ },
++ "value": {
++ "oneOf": [
++ {
++ "type": "boolean"
++ },
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ },
++ {
++ "type": "array",
++ "maxItems": 64,
++ "items": {
++ "oneOf": [
++ {
++ "type": "number"
++ },
++ {
++ "type": "string",
++ "maxLength": 200
++ }
++ ]
++ }
++ }
++ ]
++ }
++ }
++ }
++ },
++ "ui.trigger": {
++ "version": "1.0.0",
++ "owner": "contracts",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ui/trigger.v1.json",
++ "title": "UiTrigger",
++ "description": "ui.trigger@1: one trigger an Element's recipe listens for, from the fixed catalog. kind says what happened: a viewer pressing, holding or double-pressing the element, a key or gamepad binding, a chat command or text, a follow, a tip, an AI Moment, a Rundown topic, a timer or schedule, robot telemetry or an event, the stream starting or ending, a viewer-count crossing or another element's event. The fields a kind needs are the only fields it accepts. A trigger is data: it never names code or a URL, and a product may refuse a kind it does not offer yet. Stage 1 of Elements uses press and chat_command; the rest are declared here so a stage-2 element and the editor speak one language.",
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "press"
++ }
++ },
++ "description": "The element itself was pressed. This is the Viewer Deck's button."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "ms"
++ ],
++ "properties": {
++ "kind": {
++ "const": "hold"
++ },
++ "ms": {
++ "type": "integer",
++ "minimum": 300,
++ "maximum": 10000
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "double_press"
++ },
++ "within_ms": {
++ "type": "integer",
++ "minimum": 100,
++ "maximum": 2000,
++ "description": "The longest gap between the two presses; 400 by default."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "key"
++ ],
++ "properties": {
++ "kind": {
++ "const": "key"
++ },
++ "key": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ },
++ "modifiers": {
++ "type": "array",
++ "maxItems": 4,
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "ctrl",
++ "alt",
++ "shift",
++ "meta"
++ ]
++ }
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "button"
++ ],
++ "properties": {
++ "kind": {
++ "const": "gamepad"
++ },
++ "button": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "index": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 7,
++ "description": "Which connected pad; omitted means any."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "command"
++ ],
++ "properties": {
++ "kind": {
++ "const": "chat_command"
++ },
++ "command": {
++ "type": "string",
++ "pattern": "^![a-z0-9_-]{1,32}$",
++ "description": "The command without arguments, lower case, e.g. !dance."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "words"
++ ],
++ "properties": {
++ "kind": {
++ "const": "chat_text"
++ },
++ "words": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40
++ }
++ },
++ "mode": {
++ "enum": [
++ "any",
++ "all"
++ ],
++ "description": "any (default) matches one of the words; all matches every one."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "follow"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "tip"
++ },
++ "min": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "The smallest tip, in Vibes, that fires the trigger; omitted means any tip."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "ai_moment"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "rundown_topic"
++ },
++ "words": {
++ "type": "array",
++ "maxItems": 8,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 40
++ },
++ "description": "Optional words the topic has to mention; omitted means any topic."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "every_seconds"
++ ],
++ "properties": {
++ "kind": {
++ "const": "timer"
++ },
++ "every_seconds": {
++ "type": "integer",
++ "minimum": 5,
++ "maximum": 86400
++ },
++ "start": {
++ "enum": [
++ "live",
++ "always"
++ ],
++ "description": "live (default) counts only while the channel is live; always counts from the element being active."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "cron",
++ "timezone"
++ ],
++ "properties": {
++ "kind": {
++ "const": "schedule"
++ },
++ "cron": {
++ "type": "string",
++ "pattern": "^[0-9*/, -]{9,80}$",
++ "description": "A five-field cron in the person's timezone."
++ },
++ "timezone": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 64
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "metric",
++ "op",
++ "value"
++ ],
++ "properties": {
++ "kind": {
++ "const": "robot_telemetry"
++ },
++ "metric": {
++ "enum": [
++ "battery_percent",
++ "latency_ms",
++ "stuck",
++ "temperature_c"
++ ]
++ },
++ "op": {
++ "enum": [
++ "lt",
++ "gt"
++ ]
++ },
++ "value": {
++ "type": "number"
++ },
++ "for_seconds": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 3600,
++ "description": "How long the condition must hold; 0 (default) means at once."
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "event"
++ ],
++ "properties": {
++ "kind": {
++ "const": "robot_event"
++ },
++ "event": {
++ "enum": [
++ "estop",
++ "stopped",
++ "disconnected",
++ "command_refused",
++ "button"
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream_start"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind"
++ ],
++ "properties": {
++ "kind": {
++ "const": "stream_end"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "op",
++ "count"
++ ],
++ "properties": {
++ "kind": {
++ "const": "viewer_count"
++ },
++ "op": {
++ "enum": [
++ "above",
++ "below"
++ ]
++ },
++ "count": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100000000
++ }
++ }
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "element_id"
++ ],
++ "properties": {
++ "kind": {
++ "const": "element_event"
++ },
++ "element_id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "event": {
++ "enum": [
++ "pressed",
++ "state",
++ "shown"
++ ],
++ "description": "pressed (default) fires after that element ran; state and shown fire when Live broadcasts them."
++ }
++ }
++ }
++ ]
++ }
++ },
++ "live.element.pressed": {
++ "version": "1.0.0",
++ "owner": "live",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.element.pressed.v1.json",
++ "title": "LiveElementPressedPayload",
++ "description": "live.element.pressed v1 (OpenVibe.Live server/elements): a viewer's element press was accepted and ran. It carries the public facts the stream should see: the channel, the element's id and name, the presser's public name and subject, the trigger that fired, the channel points it cost and the element-state revision it left behind. It never carries the presser's email, address, account balance or anything private; a refused press does not produce it. Envelope: subject { type: element, id: el_… }, actor the presser's subject, visibility public, priority low. Also written to the channel's AI timeline as a moment, so the stream's own timeline can cite 'chat made the robot dance'.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "channel",
++ "element_id",
++ "element_name",
++ "presser",
++ "presser_name",
++ "trigger",
++ "at"
++ ],
++ "properties": {
++ "channel": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "username",
++ "display_name"
++ ],
++ "properties": {
++ "username": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$"
++ },
++ "display_name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ },
++ "description": "The public channel facts (the channel page is public already)."
++ },
++ "element_id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "element_name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "presser": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "presser_name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 120,
++ "description": "The display name the stream shows, never an email or an account id."
++ },
++ "trigger": {
++ "$ref": "../../ui/trigger.v1.json"
++ },
++ "points_spent": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Channel points the press cost; 0 when it was free."
++ },
++ "state_revision": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "The element-state revision after the actions ran; 0 when the element has no state yet."
++ },
++ "at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "live.element.state": {
++ "version": "1.0.0",
++ "owner": "live",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.element.state.v1.json",
++ "title": "LiveElementStatePayload",
++ "description": "live.element.state v1 (OpenVibe.Live server/elements): an element's own store changed, broadcast to everyone watching through Live's existing events path. Carries the channel's public facts and the same store as ui.element-state@1: the element instance, a revision that grows by one per accepted change, the values themselves and when. It is public state (a counter, a score, a wheel's entries), never the presser's data: a value may not be an object, a markup string or a URL, and the payload carries no account.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "channel",
++ "element_id",
++ "revision",
++ "values",
++ "updated_at"
++ ],
++ "properties": {
++ "channel": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "username",
++ "display_name"
++ ],
++ "properties": {
++ "username": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$"
++ },
++ "display_name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ }
++ }
++ },
++ "element_id": {
++ "type": "string",
++ "pattern": "^el_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "values": {
++ "$ref": "../../ui/element-state.v1.json#/$defs/values"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "ai.deep-dive": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/ai/deep-dive.v1.json",
++ "title": "AiDeepDive",
++ "description": "A deep dive: expert design research run by OpenVibe.AI (workflow design.deep_dive) for OpenVibe.Exchange's /deep-dive page and, through the API, OpenVibe.Design. One document holds the question and depth (the input), the seven steps, every source read, every capture (a private Media object per viewport and theme, with its DOM facts and Expert extras), the per-site analysis, the synthesis across sites, the recommendations checked against OpenVibe's own rules, and the receipt of every model call and capture. Every claim carries its evidence: the site, the capture or crop, the URL and the date it was read. Nothing is copied: captures are private to the run's owner and the report shows them as attributed commentary.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "schema",
++ "run_id",
++ "status",
++ "input",
++ "steps",
++ "sources",
++ "captures",
++ "analysis",
++ "synthesis",
++ "recommendations",
++ "receipt",
++ "gaps",
++ "created_at",
++ "updated_at"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The run id; a deep dive is a run (GET /api/v1/deep-dives/{id})."
++ },
++ "schema": {
++ "const": "ai.deep-dive@1"
++ },
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "enum": [
++ "queued",
++ "running",
++ "choosing",
++ "succeeded",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "question",
++ "depth",
++ "max_sites"
++ ],
++ "properties": {
++ "question": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500
++ },
++ "depth": {
++ "enum": [
++ "quick",
++ "thorough",
++ "expert"
++ ]
++ },
++ "max_sites": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 30
++ },
++ "sites": {
++ "type": "array",
++ "maxItems": 30,
++ "items": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "description": "Sites the person named when starting the run."
++ }
++ }
++ },
++ "steps": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "key",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "enum": [
++ "plan",
++ "search",
++ "choose",
++ "capture",
++ "analyse",
++ "synthesise",
++ "report"
++ ]
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "running",
++ "done",
++ "skipped",
++ "failed"
++ ]
++ },
++ "detail": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 300
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "sources": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "kind",
++ "title",
++ "url",
++ "read_at"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "kind": {
++ "enum": [
++ "web",
++ "picture",
++ "icon",
++ "project",
++ "rules",
++ "component",
++ "named"
++ ]
++ },
++ "title": {
++ "type": "string",
++ "maxLength": 400
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "publisher": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200
++ },
++ "engine": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 80
++ },
++ "snippet": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "license": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 120
++ },
++ "read_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "captures": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "source_id",
++ "url",
++ "captured_at",
++ "viewport",
++ "theme",
++ "full_page",
++ "crops"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^cap_[A-Za-z0-9_-]{1,40}$"
++ },
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "final_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "title": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 400
++ },
++ "captured_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "viewport": {
++ "enum": [
++ 390,
++ 1280
++ ]
++ },
++ "theme": {
++ "enum": [
++ "light",
++ "dark"
++ ]
++ },
++ "full_page": {
++ "type": "boolean"
++ },
++ "media_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "width": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1
++ },
++ "height": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1
++ },
++ "crops": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "media_id",
++ "y",
++ "height"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^crop_[A-Za-z0-9_-]{1,40}$"
++ },
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "y": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "height": {
++ "type": "integer",
++ "minimum": 1
++ }
++ }
++ }
++ },
++ "dom": {
++ "type": [
++ "object",
++ "null"
++ ]
++ },
++ "extras": {
++ "type": [
++ "object",
++ "null"
++ ]
++ },
++ "skip": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "Why the capture did not happen: robots_disallowed, not_public, no_chrome, timeout, …"
++ },
++ "cached": {
++ "type": "boolean"
++ }
++ }
++ }
++ },
++ "analysis": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "url",
++ "summary",
++ "strengths",
++ "weaknesses",
++ "evidence"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "summary": {
++ "type": "string",
++ "maxLength": 4000
++ },
++ "first_impression": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "hierarchy": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "density": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "type": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "colour": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "motion": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "words": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "strengths": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "weaknesses": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "accessibility": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "speed": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "synthesis": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "patterns",
++ "comparison",
++ "best",
++ "mistakes",
++ "gap",
++ "reference_tokens",
++ "do",
++ "dont",
++ "accessibility",
++ "speed"
++ ],
++ "properties": {
++ "patterns": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name",
++ "what",
++ "sites",
++ "evidence"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "maxLength": 160
++ },
++ "what": {
++ "type": "string",
++ "maxLength": 1200
++ },
++ "sites": {
++ "type": "array",
++ "maxItems": 30,
++ "items": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ }
++ },
++ "usage_count": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 30
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "comparison": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "columns",
++ "rows"
++ ],
++ "properties": {
++ "columns": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 120
++ }
++ },
++ "rows": {
++ "type": "array",
++ "items": {
++ "type": "array",
++ "maxItems": 12,
++ "items": {
++ "type": "string",
++ "maxLength": 1000
++ }
++ }
++ }
++ }
++ },
++ "best": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "what",
++ "evidence"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "what": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ },
++ "mistakes": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "what",
++ "evidence"
++ ],
++ "properties": {
++ "what": {
++ "type": "string",
++ "maxLength": 1200
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "gap": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "reference_tokens": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "palettes",
++ "type_scales",
++ "spacing"
++ ],
++ "properties": {
++ "palettes": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "colours"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "colours": {
++ "type": "array",
++ "maxItems": 16,
++ "items": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++ }
++ },
++ "type_scales": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "families",
++ "sizes"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "families": {
++ "type": "array",
++ "maxItems": 8,
++ "items": {
++ "type": "string",
++ "maxLength": 120
++ }
++ },
++ "sizes": {
++ "type": "array",
++ "maxItems": 16,
++ "items": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++ }
++ },
++ "spacing": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "source_id",
++ "steps"
++ ],
++ "properties": {
++ "source_id": {
++ "type": "string",
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "steps": {
++ "type": "array",
++ "maxItems": 16,
++ "items": {
++ "type": "string",
++ "maxLength": 40
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "do": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "dont": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "accessibility": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ },
++ "speed": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "maxLength": 600
++ }
++ }
++ }
++ },
++ "recommendations": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "what",
++ "why",
++ "rule",
++ "evidence"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^rec_[A-Za-z0-9_-]{1,40}$"
++ },
++ "what": {
++ "type": "string",
++ "maxLength": 1200
++ },
++ "why": {
++ "type": "string",
++ "maxLength": 2000
++ },
++ "rule": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "status",
++ "text"
++ ],
++ "properties": {
++ "id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 120
++ },
++ "status": {
++ "enum": [
++ "follows",
++ "would_change",
++ "none"
++ ]
++ },
++ "text": {
++ "type": "string",
++ "maxLength": 600
++ }
++ }
++ },
++ "evidence": {
++ "$ref": "#/$defs/evidence"
++ }
++ }
++ }
++ },
++ "receipt": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "price_usd",
++ "held_usd",
++ "cost_usd",
++ "models",
++ "captures_count",
++ "synthetic"
++ ],
++ "properties": {
++ "price_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "held_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "captures_count": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "synthetic": {
++ "type": "boolean"
++ },
++ "models": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "step",
++ "provider",
++ "model",
++ "tokens_in",
++ "tokens_out",
++ "cost_usd",
++ "ms"
++ ],
++ "properties": {
++ "step": {
++ "enum": [
++ "plan",
++ "choose",
++ "analyse",
++ "synthesise"
++ ]
++ },
++ "provider": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 80
++ },
++ "model": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200
++ },
++ "tokens_in": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "tokens_out": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ },
++ "ms": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++ }
++ }
++ },
++ "gaps": {
++ "type": "array",
++ "maxItems": 40,
++ "items": {
++ "type": "string",
++ "maxLength": 400
++ }
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "evidence": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "claim",
++ "url",
++ "read_at"
++ ],
++ "properties": {
++ "claim": {
++ "type": "string",
++ "maxLength": 600
++ },
++ "source_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^src_[A-Za-z0-9_-]{1,40}$"
++ },
++ "capture_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^cap_[A-Za-z0-9_-]{1,40}$"
++ },
++ "crop_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^crop_[A-Za-z0-9_-]{1,40}$"
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "read_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "ai.deep_dive.updated": {
++ "version": "1.0.0",
++ "owner": "ai",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/ai.deep_dive.updated.v1.json",
++ "title": "AiDeepDiveUpdatedPayload",
++ "description": "ai.deep_dive.updated v1 (OpenVibe.AI server/deep-dive): a deep dive's step changed or it ended. One event per step transition and one at the end, so a page (OpenVibe.Exchange's /deep-dive) can follow a run without polling, and OpenVibe.Design can attach a finished report. The payload carries the run and deep-dive ids, the step, the progress and the status; never the question's answer, a capture or a prompt. Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low (important on failed), actor service:ai.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "deep_dive_id",
++ "run_id",
++ "status",
++ "step",
++ "progress",
++ "steps",
++ "depth",
++ "requester",
++ "on_behalf_of",
++ "url",
++ "error",
++ "occurred_at"
++ ],
++ "properties": {
++ "deep_dive_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "run_id": {
++ "type": "string",
++ "pattern": "^run_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "status": {
++ "enum": [
++ "queued",
++ "running",
++ "choosing",
++ "succeeded",
++ "failed",
++ "cancelled"
++ ]
++ },
++ "step": {
++ "enum": [
++ "plan",
++ "search",
++ "choose",
++ "capture",
++ "analyse",
++ "synthesise",
++ "report",
++ "done",
++ null
++ ]
++ },
++ "progress": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1
++ },
++ "steps": {
++ "type": "array",
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "key",
++ "status"
++ ],
++ "properties": {
++ "key": {
++ "enum": [
++ "plan",
++ "search",
++ "choose",
++ "capture",
++ "analyse",
++ "synthesise",
++ "report"
++ ]
++ },
++ "status": {
++ "enum": [
++ "pending",
++ "running",
++ "done",
++ "skipped",
++ "failed"
++ ]
++ }
++ }
++ }
++ },
++ "depth": {
++ "enum": [
++ "quick",
++ "thorough",
++ "expert"
++ ]
++ },
++ "requester": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "on_behalf_of": {
++ "anyOf": [
++ {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://"
++ },
++ "error": {
++ "anyOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "code",
++ "detail"
++ ],
++ "properties": {
++ "code": {
++ "type": "string",
++ "maxLength": 80
++ },
++ "detail": {
++ "type": "string",
++ "maxLength": 500
++ }
++ }
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "occurred_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
}
}
}
ddiff --git a/generated/openapi/actor.json b/generated/openapi/actor.json
iindex 27b87e40..9e8433cb 100644
---- a/generated/openapi/actor.json
++++ b/generated/openapi/actor.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Actor API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Actor as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Actor as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/ai.json b/generated/openapi/ai.json
iindex 03179707..b1a4dd33 100644
---- a/generated/openapi/ai.json
++++ b/generated/openapi/ai.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.AI API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
@@@ -3986,6 +3986,62 @@
}
}
},
++ "/api/v1/ui/layout/suggest": {
++ "post": {
++ "operationId": "post_api_v1_ui_layout_suggest",
++ "summary": "Turn a person's words into a ui.layout@1 for one surface, using only the parts its ui.surface@1 catalog has.",
++ "description": "**ai.layout.suggest** (first-party): Turn a person's words into a ui.layout@1 for one surface, using only the parts its ui.surface@1 catalog has. The answer is checked by openvibe-sdk/ui-layout against the same catalog; an invalid answer gets one retry with the validator's reasons, then the task says plainly that it could not do it. The suggestion is previewed, never applied. Runs on the cheap structured-output route with the per-person limits every AI run uses, and its receipt names the model. POST /api/v1/ui/layout/suggest with a service token; OpenVibe.Network calls it for the signed-in person and names them in X-OV-Subject.",
++ "tags": [
++ "ai.layout"
++ ],
++ "x-openvibe-capabilities": [
++ "ai.layout.suggest"
++ ],
++ "x-openvibe-visibility": [
++ "first-party"
++ ],
++ "x-openvibe-quota-class": "ai-run",
++ "security": [
++ {
++ "openvibeToken": [
++ "ai.layout.suggest"
++ ]
++ }
++ ],
++ "requestBody": {
++ "required": false,
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/ui.layout-request.v1"
++ }
++ }
++ }
++ },
++ "responses": {
++ "2XX": {
++ "description": "Success",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/ui.layout-suggestion.v1"
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
"/api/v1/usage": {
"get": {
"operationId": "get_api_v1_usage",
@@@ -10110,6 +10166,750 @@
"description": "AgentSubject: an agent (OpenVibe.Actor, or a developer app's agent) acting under grants its owner delegated (roadmap WS-Z2). In an event envelope, on_behalf_of names the owner."
}
]
++ },
++ "ui.layout-request.v1": {
++ "title": "UiLayoutRequest",
++ "description": "ui.layout-request@1: what an editor sends OpenVibe.AI's ui.layout.suggest task, and what Network forwards for a signed-in person. It is the surface's catalog, the layout shown now (or null for the default one) and the words the person typed (max 500 characters), for one device class. The catalog is the only power the answer can use; the current layout is the starting arrangement; the words never contain markup that becomes part of it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "catalog",
++ "layout",
++ "words",
++ "device"
++ ],
++ "properties": {
++ "catalog": {
++ "$ref": "#/components/schemas/ui.surface.v1"
++ },
++ "layout": {
++ "oneOf": [
++ {
++ "$ref": "#/components/schemas/ui.layout.v1"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The arrangement shown now, or null when the person is looking at the default."
++ },
++ "words": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 500,
++ "description": "The person's own description, e.g. \"put Stop on the left and make it huge\"."
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ }
++ }
++ },
++ "ui.layout-suggestion.v1": {
++ "title": "UiLayoutSuggestion",
++ "description": "ui.layout-suggestion@1: OpenVibe.AI's answer to ui.layout.suggest. ok true carries the layout, already checked by openvibe-sdk/ui-layout against the catalog sent with the request, and a one-sentence summary of what changed plus the model's reasons. ok false is an honest refusal: no layout, one sentence saying what it could not do, and the validator's reasons in plain words (for example \"Stop has to stay visible.\"). A receipt names the model that answered, with the run and the route. The suggestion is previewed, never applied silently; Network or the product stores it only after the person accepts it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "ok",
++ "layout",
++ "summary",
++ "reasons"
++ ],
++ "properties": {
++ "ok": {
++ "type": "boolean"
++ },
++ "layout": {
++ "oneOf": [
++ {
++ "$ref": "#/components/schemas/ui.layout.v1"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "description": "The validated layout when ok is true; null when it is false."
++ },
++ "summary": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300,
++ "description": "One sentence saying what changed, or what could not be done."
++ },
++ "reasons": {
++ "type": "array",
++ "maxItems": 20,
++ "items": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300
++ },
++ "description": "Why this arrangement, or the validator's reasons for refusing one."
++ },
++ "receipt": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "model"
++ ],
++ "properties": {
++ "model": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "provider": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 100
++ },
++ "route": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "run_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "cost_usd": {
++ "type": "number",
++ "minimum": 0
++ }
++ },
++ "description": "Which model answered: the same receipt every AI run carries, at the task's own shape."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "ok": {
++ "const": true
++ }
++ },
++ "required": [
++ "ok"
++ ]
++ },
++ "then": {
++ "properties": {
++ "layout": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "ok": {
++ "const": false
++ }
++ },
++ "required": [
++ "ok"
++ ]
++ },
++ "then": {
++ "properties": {
++ "layout": {
++ "type": "null"
++ }
++ }
++ }
++ }
++ ]
++ },
++ "ui.layout.v1": {
++ "title": "UiLayout",
++ "description": "ui.layout@1: one person's arrangement of one surface on one device class. A layout names the surface and its catalog version, the device, its own name, its author and its sharing (private, link or gallery), and one entry per part it places. A part is visible or not, sits on the surface's grid (x, y, w, h in cells) or docks to an edge for an overlay, and carries a style (a theme token tint, a shape, opacity 0.4-1 and the label) and bindings. It is data only: no URL, no markup and no code may appear anywhere in it, and every part id, tint, shape, opacity, size and binding must be one the catalog allows. openvibe-sdk/ui-layout validates it before any server, editor or AI answer stores or shows it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "surface",
++ "surface_version",
++ "device",
++ "name",
++ "author",
++ "sharing",
++ "parts"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^lay_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Assigned by OpenVibe.Network when the layout is stored; absent in a draft, a share answer or an AI suggestion."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Assigned by the store; every change to a stored layout moves it by one."
++ },
++ "active": {
++ "type": "boolean",
++ "description": "Set by OpenVibe.Network: the one layout a product draws for this surface on this device class. A draft, a share answer and an AI suggestion carry no active field."
++ },
++ "surface": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80
++ },
++ "surface_version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "The person's name for this layout; \"My phone controls\" is fine."
++ },
++ "author": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "subject": {
++ "$ref": "#/components/schemas/identity.subject-ref.v1"
++ }
++ },
++ "description": "The author's display name and, for a private or link layout, the subject. A share or gallery answer carries the display name only, never the account."
++ },
++ "sharing": {
++ "enum": [
++ "private",
++ "link",
++ "gallery"
++ ]
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/part"
++ }
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "place": {
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "x",
++ "y",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "x": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 23
++ },
++ "y": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "The part's cell on the surface grid."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "dock",
++ "order",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "dock": {
++ "enum": [
++ "left",
++ "right",
++ "top",
++ "bottom"
++ ]
++ },
++ "order": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 63
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "For an overlay: the edge the part docks to, its order along that edge, and its size in cells."
++ }
++ ]
++ },
++ "style": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "tint",
++ "shape",
++ "opacity",
++ "label"
++ ],
++ "properties": {
++ "tint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "A theme token, never a colour: the theme decides what it is, so contrast follows the theme."
++ },
++ "shape": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ },
++ "opacity": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "label": {
++ "type": "boolean"
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "keys",
++ "gamepad"
++ ],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ }
++ }
++ }
++ },
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "visible",
++ "place",
++ "style",
++ "bindings"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "visible": {
++ "type": "boolean"
++ },
++ "place": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/place"
++ },
++ "style": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/style"
++ },
++ "bindings": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/bindings"
++ }
++ }
++ }
++ }
++ },
++ "ui.surface.v1": {
++ "title": "UiSurface",
++ "description": "ui.surface@1: the catalog a product publishes for one surface people may arrange (bot.controls, bot.overlay, live.channel, dock, chat.composer, navbar.shortcuts). It names the surface, its device classes and grid, the theme tokens its styles draw from, and every part a layout may place. A part says what it is (stick, button, slider, readout, video, list), whether it is required or pressed, its minimum cells and the minimum pixels the product guarantees (44 px for anything pressed, larger for Stop), the sizes and styles it allows, the bindings it accepts, and the parts it must never cover. A layout is validated against this catalog (openvibe-sdk/ui-layout); a layout can arrange only what the catalog has and can never add a power. The catalog is data: a layout or an AI answer never carries code or a URL.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "version",
++ "name",
++ "devices",
++ "grid",
++ "theme_tokens",
++ "parts"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80,
++ "description": "The surface id: <product>.<surface>, e.g. bot.controls."
++ },
++ "version": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The catalog version. A layout names the version it was made against."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "What the surface is called where a person sees it."
++ },
++ "description": {
++ "type": "string",
++ "maxLength": 500
++ },
++ "devices": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 3,
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "description": "The device classes this surface offers."
++ },
++ "grid": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "columns",
++ "cell_px"
++ ],
++ "properties": {
++ "columns": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "phone",
++ "tablet",
++ "computer"
++ ],
++ "description": "The grid's columns per device class: 4 on a phone and 12 on a computer in the design; a product may choose otherwise within these bounds.",
++ "properties": {
++ "phone": {
++ "type": "integer",
++ "minimum": 2,
++ "maximum": 8
++ },
++ "tablet": {
++ "type": "integer",
++ "minimum": 2,
++ "maximum": 16
++ },
++ "computer": {
++ "type": "integer",
++ "minimum": 2,
++ "maximum": 24
++ }
++ }
++ },
++ "cell_px": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "phone",
++ "tablet",
++ "computer"
++ ],
++ "description": "The smallest a cell is rendered at that device class, in CSS pixels, at the narrowest supported width. The validator multiplies a part's cells by this to check the 44 px rule.",
++ "properties": {
++ "phone": {
++ "type": "integer",
++ "minimum": 24,
++ "maximum": 200
++ },
++ "tablet": {
++ "type": "integer",
++ "minimum": 24,
++ "maximum": 200
++ },
++ "computer": {
++ "type": "integer",
++ "minimum": 24,
++ "maximum": 200
++ }
++ }
++ }
++ }
++ },
++ "theme_tokens": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "description": "The theme tokens a tint may name (accent, surface, danger, ...). Never a colour value: the theme decides what the token is."
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": {
++ "$ref": "#/components/schemas/ui.surface.v1/$defs/part"
++ }
++ }
++ },
++ "$defs": {
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "name",
++ "kind",
++ "required",
++ "pressed",
++ "min",
++ "styles",
++ "bindings"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$",
++ "description": "Stable part id inside this catalog, e.g. stop, stick, battery."
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "kind": {
++ "enum": [
++ "stick",
++ "button",
++ "slider",
++ "readout",
++ "video",
++ "list"
++ ]
++ },
++ "required": {
++ "type": "boolean",
++ "description": "A required part is always visible and at its minimum size (Stop and the e-stop). A layout that hides or shrinks it is refused."
++ },
++ "pressed": {
++ "type": "boolean",
++ "description": "The part sends a command when pressed or moved; it must render at least 44 px on every side."
++ },
++ "min": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "w",
++ "h"
++ ],
++ "description": "The smallest size a layout may give the part: w and h in grid cells, and px when the product guarantees more than 44 px (Stop).",
++ "properties": {
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "px": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 400
++ }
++ }
++ },
++ "sizes": {
++ "type": "array",
++ "maxItems": 12,
++ "uniqueItems": true,
++ "items": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "w",
++ "h"
++ ],
++ "properties": {
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ }
++ },
++ "description": "Sizes the editor may offer as presets. A layout is not limited to these: it is limited to min and to the grid."
++ },
++ "styles": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "tints",
++ "shapes"
++ ],
++ "properties": {
++ "tints": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 16,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "description": "The theme tokens this part may be tinted with; every one is also in the surface's theme_tokens."
++ },
++ "shapes": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 3,
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ }
++ },
++ "opacity": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "min",
++ "max"
++ ],
++ "properties": {
++ "min": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "max": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ }
++ },
++ "description": "The opacity range this part allows; the design's range is 0.4 to 1."
++ },
++ "label": {
++ "type": "boolean",
++ "description": "Whether the editor may show the part's label on it."
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "keys",
++ "gamepad"
++ ],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ }
++ }
++ },
++ "description": "The bindings the product accepts for this part. A layout may pick a subset, never add one."
++ },
++ "never_covers": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "description": "The parts this part must never cover, in addition to the surface-wide rule that nothing covers a required part."
++ }
++ }
++ }
++ }
}
},
"securitySchemes": {
ddiff --git a/generated/openapi/bench.json b/generated/openapi/bench.json
iindex b6ff183a..cebef21e 100644
---- a/generated/openapi/bench.json
++++ b/generated/openapi/bench.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Bench API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Bench as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Bench as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/billing.json b/generated/openapi/billing.json
iindex 45b24826..b518e7af 100644
---- a/generated/openapi/billing.json
++++ b/generated/openapi/billing.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Billing API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/blog.json b/generated/openapi/blog.json
iindex 61b08b40..93030bd0 100644
---- a/generated/openapi/blog.json
++++ b/generated/openapi/blog.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Blog API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/bot.json b/generated/openapi/bot.json
iindex 1bb09440..4795e94d 100644
---- a/generated/openapi/bot.json
++++ b/generated/openapi/bot.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Bot API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Bot as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Bot as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/chat.json b/generated/openapi/chat.json
iindex 93e5f715..fbdd5b1a 100644
---- a/generated/openapi/chat.json
++++ b/generated/openapi/chat.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Chat API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/codes.json b/generated/openapi/codes.json
iindex 4c4b7e12..a2a2a17a 100644
---- a/generated/openapi/codes.json
++++ b/generated/openapi/codes.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Codes API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/community.json b/generated/openapi/community.json
iindex cc47d78e..85aa82cd 100644
---- a/generated/openapi/community.json
++++ b/generated/openapi/community.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Community API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/connect.json b/generated/openapi/connect.json
iindex ea727380..5dc73267 100644
---- a/generated/openapi/connect.json
++++ b/generated/openapi/connect.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe Connect API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe Connect as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe Connect as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/coupons.json b/generated/openapi/coupons.json
iindex cbbd01ef..2e0c3f5b 100644
---- a/generated/openapi/coupons.json
++++ b/generated/openapi/coupons.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Coupons API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/deals.json b/generated/openapi/deals.json
iindex b3db5495..6451deff 100644
---- a/generated/openapi/deals.json
++++ b/generated/openapi/deals.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Deals API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/design.json b/generated/openapi/design.json
iindex a9d4d60e..d04a36fa 100644
---- a/generated/openapi/design.json
++++ b/generated/openapi/design.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Design API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Design as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Design as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/domains.json b/generated/openapi/domains.json
iindex 2e00ad9a..0ca41238 100644
---- a/generated/openapi/domains.json
++++ b/generated/openapi/domains.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Domains API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Domains as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Domains as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/events.json b/generated/openapi/events.json
iindex 61a50c84..769df186 100644
---- a/generated/openapi/events.json
++++ b/generated/openapi/events.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Events API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/exchange.json b/generated/openapi/exchange.json
iindex e625549a..c3a595a7 100644
---- a/generated/openapi/exchange.json
++++ b/generated/openapi/exchange.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Exchange API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Exchange as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Exchange as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/food.json b/generated/openapi/food.json
iindex 151b8111..343bc581 100644
---- a/generated/openapi/food.json
++++ b/generated/openapi/food.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Food API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Food as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Food as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/games.json b/generated/openapi/games.json
iindex efd2257b..e7de0a9f 100644
---- a/generated/openapi/games.json
++++ b/generated/openapi/games.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Games API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/git.json b/generated/openapi/git.json
iindex 8ab0aaef..3083d87a 100644
---- a/generated/openapi/git.json
++++ b/generated/openapi/git.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Git API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Git as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Git as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/host.json b/generated/openapi/host.json
iindex 819088ab..ba84fa86 100644
---- a/generated/openapi/host.json
++++ b/generated/openapi/host.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Host API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/index.json b/generated/openapi/index.json
iindex f4a474f5..3190401e 100644
---- a/generated/openapi/index.json
++++ b/generated/openapi/index.json
@@@ -12,8 +12,8 @@
"service": "ai",
"name": "OpenVibe.AI",
"origin": "https://ai.openvibe.services",
-- "operations": 71,
-- "capabilities": 18
++ "operations": 72,
++ "capabilities": 19
},
{
"service": "bench",
@@@ -180,8 +180,8 @@
"service": "network",
"name": "OpenVibe.Network",
"origin": "https://openvibe.network",
-- "operations": 73,
-- "capabilities": 43
++ "operations": 74,
++ "capabilities": 44
},
{
"service": "news",
ddiff --git a/generated/openapi/inventory.json b/generated/openapi/inventory.json
iindex b21bce2e..10915dac 100644
---- a/generated/openapi/inventory.json
++++ b/generated/openapi/inventory.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Inventory API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Inventory as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Inventory as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/live.json b/generated/openapi/live.json
iindex a4670981..93f91a0c 100644
---- a/generated/openapi/live.json
++++ b/generated/openapi/live.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Live API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/mail.json b/generated/openapi/mail.json
iindex c8fd98d1..d2f8b94d 100644
---- a/generated/openapi/mail.json
++++ b/generated/openapi/mail.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Mail API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Mail as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Mail as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/media-hub.json b/generated/openapi/media-hub.json
iindex 6573fc62..57f49fe9 100644
---- a/generated/openapi/media-hub.json
++++ b/generated/openapi/media-hub.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.MediaHub API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.MediaHub as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.MediaHub as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/media.json b/generated/openapi/media.json
iindex 711e886f..6f063706 100644
---- a/generated/openapi/media.json
++++ b/generated/openapi/media.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Media API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/network.json b/generated/openapi/network.json
iindex 1d672aac..76d1d1e8 100644
---- a/generated/openapi/network.json
++++ b/generated/openapi/network.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Network API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
@@@ -2057,6 +2057,70 @@
}
}
},
++ "/api/v1/people/{subject}/layouts/{surface}": {
++ "get": {
++ "operationId": "get_api_v1_people_subject_layouts_surface",
++ "summary": "Read the active ui.layout@1 one person made for a product's own surface, with that surface's device class.",
++ "description": "**network.layout.read** (first-party): Read the active ui.layout@1 one person made for a product's own surface, with that surface's device class. A first-party service presents a service token for openvibe.network and names the person with X-OV-Subject (which must be the person in the path); it reads one layout and nothing else of their account, and never their saved or gallery layouts. The person's own token uses GET /api/v1/me/layouts/:surface instead, which reads and writes only their own rows.",
++ "tags": [
++ "network.layout"
++ ],
++ "x-openvibe-capabilities": [
++ "network.layout.read"
++ ],
++ "x-openvibe-visibility": [
++ "first-party"
++ ],
++ "x-openvibe-quota-class": "standard",
++ "security": [
++ {
++ "openvibeToken": [
++ "network.layout.read"
++ ]
++ }
++ ],
++ "parameters": [
++ {
++ "name": "subject",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ },
++ {
++ "name": "surface",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "responses": {
++ "2XX": {
++ "description": "Success",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/ui.layout.v1"
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "An error (RFC 9457 problem details)",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "$ref": "#/components/schemas/errors.problem.v1"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
"/api/v1/resources": {
"get": {
"operationId": "get_api_v1_resources",
@@@ -9635,6 +9699,260 @@
"format": "date-time"
}
}
++ },
++ "ui.layout.v1": {
++ "title": "UiLayout",
++ "description": "ui.layout@1: one person's arrangement of one surface on one device class. A layout names the surface and its catalog version, the device, its own name, its author and its sharing (private, link or gallery), and one entry per part it places. A part is visible or not, sits on the surface's grid (x, y, w, h in cells) or docks to an edge for an overlay, and carries a style (a theme token tint, a shape, opacity 0.4-1 and the label) and bindings. It is data only: no URL, no markup and no code may appear anywhere in it, and every part id, tint, shape, opacity, size and binding must be one the catalog allows. openvibe-sdk/ui-layout validates it before any server, editor or AI answer stores or shows it.",
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "surface",
++ "surface_version",
++ "device",
++ "name",
++ "author",
++ "sharing",
++ "parts"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^lay_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Assigned by OpenVibe.Network when the layout is stored; absent in a draft, a share answer or an AI suggestion."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Assigned by the store; every change to a stored layout moves it by one."
++ },
++ "active": {
++ "type": "boolean",
++ "description": "Set by OpenVibe.Network: the one layout a product draws for this surface on this device class. A draft, a share answer and an AI suggestion carry no active field."
++ },
++ "surface": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "maxLength": 80
++ },
++ "surface_version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "device": {
++ "enum": [
++ "phone",
++ "tablet",
++ "computer"
++ ]
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80,
++ "description": "The person's name for this layout; \"My phone controls\" is fine."
++ },
++ "author": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "name"
++ ],
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "subject": {
++ "$ref": "#/components/schemas/identity.subject-ref.v1"
++ }
++ },
++ "description": "The author's display name and, for a private or link layout, the subject. A share or gallery answer carries the display name only, never the account."
++ },
++ "sharing": {
++ "enum": [
++ "private",
++ "link",
++ "gallery"
++ ]
++ },
++ "parts": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 64,
++ "items": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/part"
++ }
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ },
++ "$defs": {
++ "place": {
++ "oneOf": [
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "x",
++ "y",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "x": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 23
++ },
++ "y": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "The part's cell on the surface grid."
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "dock",
++ "order",
++ "w",
++ "h"
++ ],
++ "properties": {
++ "dock": {
++ "enum": [
++ "left",
++ "right",
++ "top",
++ "bottom"
++ ]
++ },
++ "order": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 63
++ },
++ "w": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ },
++ "h": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 24
++ }
++ },
++ "description": "For an overlay: the edge the part docks to, its order along that edge, and its size in cells."
++ }
++ ]
++ },
++ "style": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "tint",
++ "shape",
++ "opacity",
++ "label"
++ ],
++ "properties": {
++ "tint": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "A theme token, never a colour: the theme decides what it is, so contrast follows the theme."
++ },
++ "shape": {
++ "enum": [
++ "round",
++ "pill",
++ "square"
++ ]
++ },
++ "opacity": {
++ "type": "number",
++ "minimum": 0.4,
++ "maximum": 1
++ },
++ "label": {
++ "type": "boolean"
++ }
++ }
++ },
++ "bindings": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "keys",
++ "gamepad"
++ ],
++ "properties": {
++ "keys": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9+_.-]{1,40}$"
++ }
++ },
++ "gamepad": {
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ }
++ }
++ }
++ },
++ "part": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "id",
++ "visible",
++ "place",
++ "style",
++ "bindings"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,39}$"
++ },
++ "visible": {
++ "type": "boolean"
++ },
++ "place": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/place"
++ },
++ "style": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/style"
++ },
++ "bindings": {
++ "$ref": "#/components/schemas/ui.layout.v1/$defs/bindings"
++ }
++ }
++ }
++ }
}
},
"securitySchemes": {
ddiff --git a/generated/openapi/news.json b/generated/openapi/news.json
iindex 00a15887..2bd3d4ee 100644
---- a/generated/openapi/news.json
++++ b/generated/openapi/news.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.News API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/openre.json b/generated/openapi/openre.json
iindex 97adadc0..2bacab43 100644
---- a/generated/openapi/openre.json
++++ b/generated/openapi/openre.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenRestream API",
-- "version": "0.409.0",
-- "description": "Routes of OpenRestream as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenRestream as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/play.json b/generated/openapi/play.json
iindex 148ee098..bc8b74f5 100644
---- a/generated/openapi/play.json
++++ b/generated/openapi/play.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Play API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Play as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Play as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/quest.json b/generated/openapi/quest.json
iindex 88afd913..1be7b943 100644
---- a/generated/openapi/quest.json
++++ b/generated/openapi/quest.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Quest API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Quest as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Quest as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/report.json b/generated/openapi/report.json
iindex bbd329a0..6b976be5 100644
---- a/generated/openapi/report.json
++++ b/generated/openapi/report.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Report API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Report as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Report as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/reviews.json b/generated/openapi/reviews.json
iindex 687b8322..338d8c83 100644
---- a/generated/openapi/reviews.json
++++ b/generated/openapi/reviews.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Reviews API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/run.json b/generated/openapi/run.json
iindex 7fbd3fc1..5372e142 100644
---- a/generated/openapi/run.json
++++ b/generated/openapi/run.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Run API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Run as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Run as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/search.json b/generated/openapi/search.json
iindex c2469975..21b8b79a 100644
---- a/generated/openapi/search.json
++++ b/generated/openapi/search.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Search API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/services.json b/generated/openapi/services.json
iindex 1aef708c..a2b87732 100644
---- a/generated/openapi/services.json
++++ b/generated/openapi/services.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Services API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Services as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Services as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/sources.json b/generated/openapi/sources.json
iindex 8bb767ac..bfb61296 100644
---- a/generated/openapi/sources.json
++++ b/generated/openapi/sources.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Sources API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/space.json b/generated/openapi/space.json
iindex 26550a7f..dae83e47 100644
---- a/generated/openapi/space.json
++++ b/generated/openapi/space.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Space API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Space as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Space as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/tips.json b/generated/openapi/tips.json
iindex 774b190a..9f74b3e4 100644
---- a/generated/openapi/tips.json
++++ b/generated/openapi/tips.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Tips API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/today.json b/generated/openapi/today.json
iindex b06dd1a1..fb876891 100644
---- a/generated/openapi/today.json
++++ b/generated/openapi/today.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Today API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Today as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Today as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/tools.json b/generated/openapi/tools.json
iindex 4af667d5..458de7f1 100644
---- a/generated/openapi/tools.json
++++ b/generated/openapi/tools.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Tools API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/trade.json b/generated/openapi/trade.json
iindex b7c9ae61..6fe15296 100644
---- a/generated/openapi/trade.json
++++ b/generated/openapi/trade.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Trade API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/vip.json b/generated/openapi/vip.json
iindex e969cd41..3607b260 100644
---- a/generated/openapi/vip.json
++++ b/generated/openapi/vip.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.VIP API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/vision.json b/generated/openapi/vision.json
iindex a6bfe466..f3828db6 100644
---- a/generated/openapi/vision.json
++++ b/generated/openapi/vision.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Vision API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Vision as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Vision as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/watch.json b/generated/openapi/watch.json
iindex b0c1cfa8..34ad29bd 100644
---- a/generated/openapi/watch.json
++++ b/generated/openapi/watch.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Watch API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Watch as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Watch as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/wiki.json b/generated/openapi/wiki.json
iindex 162a8962..ea1b738d 100644
---- a/generated/openapi/wiki.json
++++ b/generated/openapi/wiki.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Wiki API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/work.json b/generated/openapi/work.json
iindex 7182fbb2..bf1a4ed5 100644
---- a/generated/openapi/work.json
++++ b/generated/openapi/work.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Work API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Work as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Work as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/zone.json b/generated/openapi/zone.json
iindex 42dbb36f..f26a2c9b 100644
---- a/generated/openapi/zone.json
++++ b/generated/openapi/zone.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Zone API",
-- "version": "0.409.0",
-- "description": "Routes of OpenVibe.Zone as the OpenVibe contracts describe them (openvibe-contracts 0.409.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.410.0",
++ "description": "Routes of OpenVibe.Zone as the OpenVibe contracts describe them (openvibe-contracts 0.410.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex adf58d9d..b3c98c05 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -338191,3 +338191,3156 @@ export interface NetworkAgentTaskUpdatedPayload {
status: "pending" | "running" | "completed" | "failed" | "cancelled";
at: string;
}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.surface@1: the catalog a product publishes for one surface people may arrange (bot.controls, bot.overlay, live.channel, dock, chat.composer, navbar.shortcuts). It names the surface, its device classes and grid, the theme tokens its styles draw from, and every part a layout may place. A part says what it is (stick, button, slider, readout, video, list), whether it is required or pressed, its minimum cells and the minimum pixels the product guarantees (44 px for anything pressed, larger for Stop), the sizes and styles it allows, the bindings it accepts, and the parts it must never cover. A layout is validated against this catalog (openvibe-sdk/ui-layout); a layout can arrange only what the catalog has and can never add a power. The catalog is data: a layout or an AI answer never carries code or a URL.
++ */
++export interface UiSurface {
++ /**
++ * The surface id: <product>.<surface>, e.g. bot.controls.
++ */
++ id: string;
++ /**
++ * The catalog version. A layout names the version it was made against.
++ */
++ version: number;
++ /**
++ * What the surface is called where a person sees it.
++ */
++ name: string;
++ description?: string;
++ /**
++ * The device classes this surface offers.
++ *
++ * @minItems 1
++ * @maxItems 3
++ */
++ devices:
++ | ["phone" | "tablet" | "computer"]
++ | ["phone" | "tablet" | "computer", "phone" | "tablet" | "computer"]
++ | ["phone" | "tablet" | "computer", "phone" | "tablet" | "computer", "phone" | "tablet" | "computer"];
++ grid: {
++ /**
++ * The grid's columns per device class: 4 on a phone and 12 on a computer in the design; a product may choose otherwise within these bounds.
++ */
++ columns: {
++ phone: number;
++ tablet: number;
++ computer: number;
++ };
++ /**
++ * The smallest a cell is rendered at that device class, in CSS pixels, at the narrowest supported width. The validator multiplies a part's cells by this to check the 44 px rule.
++ */
++ cell_px: {
++ phone: number;
++ tablet: number;
++ computer: number;
++ };
++ };
++ /**
++ * The theme tokens a tint may name (accent, surface, danger, ...). Never a colour value: the theme decides what the token is.
++ *
++ * @minItems 1
++ * @maxItems 64
++ */
++ theme_tokens: [string, ...string[]];
++ /**
++ * @minItems 1
++ * @maxItems 64
++ */
++ parts: [
++ {
++ /**
++ * Stable part id inside this catalog, e.g. stop, stick, battery.
++ */
++ id: string;
++ name: string;
++ kind: "stick" | "button" | "slider" | "readout" | "video" | "list";
++ /**
++ * A required part is always visible and at its minimum size (Stop and the e-stop). A layout that hides or shrinks it is refused.
++ */
++ required: boolean;
++ /**
++ * The part sends a command when pressed or moved; it must render at least 44 px on every side.
++ */
++ pressed: boolean;
++ /**
++ * The smallest size a layout may give the part: w and h in grid cells, and px when the product guarantees more than 44 px (Stop).
++ */
++ min: {
++ w: number;
++ h: number;
++ px?: number;
++ };
++ /**
++ * Sizes the editor may offer as presets. A layout is not limited to these: it is limited to min and to the grid.
++ *
++ * @maxItems 12
++ */
++ sizes?:
++ | []
++ | [
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ];
++ styles: {
++ /**
++ * The theme tokens this part may be tinted with; every one is also in the surface's theme_tokens.
++ *
++ * @minItems 1
++ * @maxItems 16
++ */
++ tints:
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * @minItems 1
++ * @maxItems 3
++ */
++ shapes:
++ | ["round" | "pill" | "square"]
++ | ["round" | "pill" | "square", "round" | "pill" | "square"]
++ | ["round" | "pill" | "square", "round" | "pill" | "square", "round" | "pill" | "square"];
++ /**
++ * The opacity range this part allows; the design's range is 0.4 to 1.
++ */
++ opacity?: {
++ min: number;
++ max: number;
++ };
++ /**
++ * Whether the editor may show the part's label on it.
++ */
++ label?: boolean;
++ };
++ /**
++ * The bindings the product accepts for this part. A layout may pick a subset, never add one.
++ */
++ bindings: {
++ /**
++ * @maxItems 32
++ */
++ keys: string[];
++ /**
++ * @maxItems 32
++ */
++ gamepad: string[];
++ };
++ /**
++ * The parts this part must never cover, in addition to the surface-wide rule that nothing covers a required part.
++ *
++ * @maxItems 32
++ */
++ never_covers?: string[];
++ },
++ ...{
++ /**
++ * Stable part id inside this catalog, e.g. stop, stick, battery.
++ */
++ id: string;
++ name: string;
++ kind: "stick" | "button" | "slider" | "readout" | "video" | "list";
++ /**
++ * A required part is always visible and at its minimum size (Stop and the e-stop). A layout that hides or shrinks it is refused.
++ */
++ required: boolean;
++ /**
++ * The part sends a command when pressed or moved; it must render at least 44 px on every side.
++ */
++ pressed: boolean;
++ /**
++ * The smallest size a layout may give the part: w and h in grid cells, and px when the product guarantees more than 44 px (Stop).
++ */
++ min: {
++ w: number;
++ h: number;
++ px?: number;
++ };
++ /**
++ * Sizes the editor may offer as presets. A layout is not limited to these: it is limited to min and to the grid.
++ *
++ * @maxItems 12
++ */
++ sizes?:
++ | []
++ | [
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ]
++ | [
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ },
++ {
++ w: number;
++ h: number;
++ }
++ ];
++ styles: {
++ /**
++ * The theme tokens this part may be tinted with; every one is also in the surface's theme_tokens.
++ *
++ * @minItems 1
++ * @maxItems 16
++ */
++ tints:
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * @minItems 1
++ * @maxItems 3
++ */
++ shapes:
++ | ["round" | "pill" | "square"]
++ | ["round" | "pill" | "square", "round" | "pill" | "square"]
++ | ["round" | "pill" | "square", "round" | "pill" | "square", "round" | "pill" | "square"];
++ /**
++ * The opacity range this part allows; the design's range is 0.4 to 1.
++ */
++ opacity?: {
++ min: number;
++ max: number;
++ };
++ /**
++ * Whether the editor may show the part's label on it.
++ */
++ label?: boolean;
++ };
++ /**
++ * The bindings the product accepts for this part. A layout may pick a subset, never add one.
++ */
++ bindings: {
++ /**
++ * @maxItems 32
++ */
++ keys: string[];
++ /**
++ * @maxItems 32
++ */
++ gamepad: string[];
++ };
++ /**
++ * The parts this part must never cover, in addition to the surface-wide rule that nothing covers a required part.
++ *
++ * @maxItems 32
++ */
++ never_covers?: string[];
++ }[]
++ ];
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.layout@1: one person's arrangement of one surface on one device class. A layout names the surface and its catalog version, the device, its own name, its author and its sharing (private, link or gallery), and one entry per part it places. A part is visible or not, sits on the surface's grid (x, y, w, h in cells) or docks to an edge for an overlay, and carries a style (a theme token tint, a shape, opacity 0.4-1 and the label) and bindings. It is data only: no URL, no markup and no code may appear anywhere in it, and every part id, tint, shape, opacity, size and binding must be one the catalog allows. openvibe-sdk/ui-layout validates it before any server, editor or AI answer stores or shows it.
++ */
++export interface UiLayout {
++ /**
++ * Assigned by OpenVibe.Network when the layout is stored; absent in a draft, a share answer or an AI suggestion.
++ */
++ id?: string;
++ /**
++ * Assigned by the store; every change to a stored layout moves it by one.
++ */
++ revision?: number;
++ /**
++ * Set by OpenVibe.Network: the one layout a product draws for this surface on this device class. A draft, a share answer and an AI suggestion carry no active field.
++ */
++ active?: boolean;
++ surface: string;
++ surface_version: number;
++ device: "phone" | "tablet" | "computer";
++ /**
++ * The person's name for this layout; "My phone controls" is fine.
++ */
++ name: string;
++ /**
++ * The author's display name and, for a private or link layout, the subject. A share or gallery answer carries the display name only, never the account.
++ */
++ author: {
++ name: string;
++ subject?: SubjectRef;
++ };
++ sharing: "private" | "link" | "gallery";
++ /**
++ * @minItems 1
++ * @maxItems 64
++ */
++ parts: [
++ {
++ id: string;
++ visible: boolean;
++ place:
++ | {
++ x: number;
++ y: number;
++ w: number;
++ h: number;
++ }
++ | {
++ dock: "left" | "right" | "top" | "bottom";
++ order: number;
++ w: number;
++ h: number;
++ };
++ style: {
++ /**
++ * A theme token, never a colour: the theme decides what it is, so contrast follows the theme.
++ */
++ tint: string;
++ shape: "round" | "pill" | "square";
++ opacity: number;
++ label: boolean;
++ };
++ bindings: {
++ /**
++ * @maxItems 32
++ */
++ keys: string[];
++ /**
++ * @maxItems 32
++ */
++ gamepad: string[];
++ };
++ },
++ ...{
++ id: string;
++ visible: boolean;
++ place:
++ | {
++ x: number;
++ y: number;
++ w: number;
++ h: number;
++ }
++ | {
++ dock: "left" | "right" | "top" | "bottom";
++ order: number;
++ w: number;
++ h: number;
++ };
++ style: {
++ /**
++ * A theme token, never a colour: the theme decides what it is, so contrast follows the theme.
++ */
++ tint: string;
++ shape: "round" | "pill" | "square";
++ opacity: number;
++ label: boolean;
++ };
++ bindings: {
++ /**
++ * @maxItems 32
++ */
++ keys: string[];
++ /**
++ * @maxItems 32
++ */
++ gamepad: string[];
++ };
++ }[]
++ ];
++ updated_at?: string;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.layout-request@1: what an editor sends OpenVibe.AI's ui.layout.suggest task, and what Network forwards for a signed-in person. It is the surface's catalog, the layout shown now (or null for the default one) and the words the person typed (max 500 characters), for one device class. The catalog is the only power the answer can use; the current layout is the starting arrangement; the words never contain markup that becomes part of it.
++ */
++export interface UiLayoutRequest {
++ catalog: UiSurface;
++ /**
++ * The arrangement shown now, or null when the person is looking at the default.
++ */
++ layout: UiLayout | null;
++ /**
++ * The person's own description, e.g. "put Stop on the left and make it huge".
++ */
++ words: string;
++ device: "phone" | "tablet" | "computer";
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.layout-suggestion@1: OpenVibe.AI's answer to ui.layout.suggest. ok true carries the layout, already checked by openvibe-sdk/ui-layout against the catalog sent with the request, and a one-sentence summary of what changed plus the model's reasons. ok false is an honest refusal: no layout, one sentence saying what it could not do, and the validator's reasons in plain words (for example "Stop has to stay visible."). A receipt names the model that answered, with the run and the route. The suggestion is previewed, never applied silently; Network or the product stores it only after the person accepts it.
++ */
++export type UiLayoutSuggestion = {
++ [k: string]: unknown | undefined;
++} & {
++ ok: boolean;
++ /**
++ * The validated layout when ok is true; null when it is false.
++ */
++ layout: UiLayout | null;
++ /**
++ * One sentence saying what changed, or what could not be done.
++ */
++ summary: string;
++ /**
++ * Why this arrangement, or the validator's reasons for refusing one.
++ *
++ * @maxItems 20
++ */
++ reasons:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * Which model answered: the same receipt every AI run carries, at the task's own shape.
++ */
++ receipt?: {
++ model: string;
++ provider?: string;
++ route?: string;
++ run_id?: string;
++ cost_usd?: number;
++ };
++};
++
++/** [email protected] (owner: network) */
++/**
++ * network.layout.changed v1 (OpenVibe.Network server/layouts, written into network_event_outbox in the transaction that changes a stored layout). One event per change to one person's ui.layout@1: set active, saved, renamed, deleted, shared, unshared, published to the gallery, unpublished, copied from the gallery, or reset to the default. Envelope: subject { type: user, id } and the layout's revision, actor the person, visibility internal. The payload carries no account data and no layout contents, so an open page can announce a change and refetch with the person's own token.
++ */
++export interface NetworkLayoutChangedPayload {
++ surface: string;
++ device: "phone" | "tablet" | "computer";
++ layout_id: string;
++ change:
++ | "active"
++ | "saved"
++ | "renamed"
++ | "deleted"
++ | "shared"
++ | "unshared"
++ | "published"
++ | "unpublished"
++ | "copied"
++ | "reset";
++ revision: number;
++ sharing?: "private" | "link" | "gallery";
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.action@1: one action an Element's recipe may request, from the fixed catalog, with the product that authorises it. An element never has a power of its own: the host asks the product named here, and that product checks the presser's role, the element's declared capability, its cooldown and cost, and its own owner limits before it does anything. kind is also the capability id an element declares in ui.element@1, and product is fixed by kind: bot for robot.drive_pulse, robot.head_preset, robot.headlight, robot.say and robot.macro; chat for chat.message, chat.pin and chat.poll; live for the screen, stream and webhook actions; ai for ai.ask; actor for actor.task; billing for money.tip_goal. A robot action is a request through Bot's existing command path: turns, Stop, the e-stop and owner takeover are never bypassed. Money never moves from an action without Billing's own confirmation.
++ */
++export type UiAction =
++ | {
++ kind: "robot.drive_pulse";
++ product: "bot";
++ direction: "forward" | "back" | "left" | "right" | "spin_left" | "spin_right";
++ ms: number;
++ /**
++ * Fraction of the robot's allowed speed; omitted means the owner's default for the role.
++ */
++ speed?: number;
++ }
++ | {
++ kind: "robot.head_preset";
++ product: "bot";
++ /**
++ * A head tilt preset. Bot still checks the robot profile and the owner's limits.
++ */
++ preset: "up" | "center" | "down";
++ }
++ | {
++ kind: "robot.headlight";
++ product: "bot";
++ on: boolean;
++ }
++ | {
++ kind: "robot.say";
++ product: "bot";
++ text: string;
++ }
++ | {
++ kind: "robot.macro";
++ product: "bot";
++ macro: string;
++ }
++ | {
++ kind: "chat.message";
++ product: "chat";
++ text: string;
++ }
++ | {
++ kind: "chat.pin";
++ product: "chat";
++ /**
++ * trigger pins the message that caused this action; latest pins the newest chat message.
++ */
++ source: "trigger" | "latest";
++ }
++ | {
++ kind: "chat.poll";
++ product: "chat";
++ question: string;
++ /**
++ * @minItems 2
++ * @maxItems 10
++ */
++ options:
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string];
++ }
++ | {
++ kind: "screen.alert";
++ product: "live";
++ title: string;
++ text?: string;
++ }
++ | {
++ kind: "screen.animation";
++ product: "live";
++ /**
++ * An animation the page or overlay has; an unknown name is refused, never fetched.
++ */
++ name: string;
++ }
++ | {
++ kind: "screen.confetti";
++ product: "live";
++ intensity?: "low" | "medium" | "high";
++ }
++ | {
++ kind: "screen.sound";
++ product: "live";
++ /**
++ * A sound from the streamer's own sound list; Live never fetches a URL for it.
++ */
++ sound: string;
++ volume?: number;
++ }
++ | {
++ kind: "screen.tts";
++ product: "live";
++ text: string;
++ /**
++ * A voice from the streamer's own voice list.
++ */
++ voice: string;
++ }
++ | {
++ kind: "screen.counter";
++ product: "live";
++ key: string;
++ by: number;
++ }
++ | {
++ kind: "screen.state";
++ product: "live";
++ key: string;
++ value: boolean | number | string | (number | string)[];
++ }
++ | {
++ kind: "screen.visibility";
++ product: "live";
++ element: string;
++ visible: boolean;
++ }
++ | {
++ kind: "screen.scene";
++ product: "live";
++ /**
++ * A scene the layout has; an unknown scene is refused.
++ */
++ scene: string;
++ }
++ | {
++ kind: "stream.clip";
++ product: "live";
++ /**
++ * How far back the clip starts, at most 30 seconds.
++ */
++ seconds?: number;
++ }
++ | {
++ kind: "stream.moment";
++ product: "live";
++ label?: string;
++ }
++ | {
++ kind: "stream.title";
++ product: "live";
++ title: string;
++ }
++ | {
++ kind: "stream.category";
++ product: "live";
++ category: string;
++ }
++ | {
++ kind: "ai.ask";
++ product: "ai";
++ prompt: string;
++ model?: string;
++ max_cost_usd?: number;
++ }
++ | {
++ kind: "actor.task";
++ product: "actor";
++ task: string;
++ }
++ | {
++ kind: "webhook.call";
++ product: "live";
++ endpoint: string;
++ }
++ | {
++ kind: "money.tip_goal";
++ product: "billing";
++ goal: string;
++ };
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.element@1: an Element people make and place on a surface: a look, a behaviour and, only when a recipe is not enough, a mod. LOOK draws from OpenVibe.Design's components and the theme's tokens, so it follows the person's theme, the seasons and dark/light; it carries no markup and no URL. RECIPE is data only: one or more triggers from ui.trigger@1, the conditions that decide who may fire it and what it costs, and one or more actions from ui.action@1, checked against CAPABILITIES. A capability is an action kind the element declares; the host refuses an action the element did not declare, and the product named by the action still checks the presser's role, the cooldown, the cost and its own limits. MOD is an optional reference to a code module (id, version and digest) for the stage where a recipe is not enough; the module never adds a power. Conditions use channel points only in stage 1: no Vibes, no money, and Billing still confirms any charge itself. The id and revision are assigned by the store; a draft has neither.
++ */
++export interface UiElement {
++ /**
++ * Assigned by the store when the element is saved; absent in a draft.
++ */
++ id?: string;
++ /**
++ * Assigned by the store; every saved change moves it by one.
++ */
++ revision?: number;
++ name: string;
++ description?: string;
++ /**
++ * The author's display name and, for a private or link element, the subject. A gallery or copy answer carries the display name only, never the account.
++ */
++ author: {
++ name: string;
++ subject?: SubjectRef;
++ };
++ sharing: "private" | "link" | "gallery";
++ look: {
++ /**
++ * An OpenVibe.Design component name (design.component@1), e.g. button or meter. A name the page does not have is refused, never fetched.
++ */
++ component: string;
++ /**
++ * The words on the element, e.g. Make it dance.
++ */
++ label: string;
++ /**
++ * A glyph name from the design icon set.
++ */
++ icon?: string;
++ /**
++ * The design component's own props (the sliders the editor shows). Numbers, strings and booleans only; no nesting.
++ */
++ props?: {
++ [k: string]: (boolean | number | string) | undefined;
++ };
++ style?: {
++ /**
++ * A theme token, never a colour: the theme decides what it is, so contrast follows the theme.
++ */
++ tint?: string;
++ shape?: "round" | "pill" | "square";
++ opacity?: number;
++ show_label?: boolean;
++ };
++ };
++ recipe: {
++ /**
++ * @minItems 1
++ * @maxItems 8
++ */
++ triggers:
++ | [UiTrigger]
++ | [UiTrigger, UiTrigger]
++ | [UiTrigger, UiTrigger, UiTrigger]
++ | [UiTrigger, UiTrigger, UiTrigger, UiTrigger]
++ | [UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger]
++ | [UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger]
++ | [UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger]
++ | [UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger, UiTrigger];
++ conditions?: {
++ /**
++ * Who may fire it. A higher role admits the lower ones where the product says so (an operator is admitted to subscriber-only); owner is the channel's owner alone. Default everyone.
++ */
++ role?: "everyone" | "follower" | "subscriber" | "vip" | "operator" | "owner";
++ /**
++ * Channel points the presser spends, from Live's points. Stage 1 has no other currency, and an element never charges money.
++ */
++ cost?: {
++ points: number;
++ };
++ /**
++ * How long the same person must wait, and how long everybody must wait, before it can fire again.
++ */
++ cooldown?: {
++ per_person_seconds?: number;
++ overall_seconds?: number;
++ };
++ /**
++ * The most times it may fire in a UTC day, per person and for everyone.
++ */
++ daily_cap?: {
++ per_person?: number;
++ overall?: number;
++ };
++ /**
++ * When true the element fires only while the channel is live.
++ */
++ live_only?: boolean;
++ /**
++ * When true a robot action is requested only while the presser has the robot's turn; Bot still decides.
++ */
++ robot_turn_only?: boolean;
++ /**
++ * Fire only one time in N. The host draws the lot, so a refused draw costs nothing.
++ */
++ chance?: {
++ one_in: number;
++ };
++ };
++ /**
++ * @minItems 1
++ * @maxItems 16
++ */
++ actions:
++ | [UiAction]
++ | [UiAction, UiAction]
++ | [UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction, UiAction]
++ | [
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction
++ ]
++ | [
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction
++ ]
++ | [
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction
++ ]
++ | [
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction
++ ]
++ | [
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction,
++ UiAction
++ ];
++ };
++ /**
++ * The action kinds this element may request. Every action in the recipe must be declared here; the host refuses the rest.
++ *
++ * @minItems 1
++ * @maxItems 32
++ */
++ capabilities: [
++ (
++ | "robot.drive_pulse"
++ | "robot.head_preset"
++ | "robot.headlight"
++ | "robot.say"
++ | "robot.macro"
++ | "chat.message"
++ | "chat.pin"
++ | "chat.poll"
++ | "screen.alert"
++ | "screen.animation"
++ | "screen.confetti"
++ | "screen.sound"
++ | "screen.tts"
++ | "screen.counter"
++ | "screen.state"
++ | "screen.visibility"
++ | "screen.scene"
++ | "stream.clip"
++ | "stream.moment"
++ | "stream.title"
++ | "stream.category"
++ | "ai.ask"
++ | "actor.task"
++ | "webhook.call"
++ | "money.tip_goal"
++ ),
++ ...(
++ | "robot.drive_pulse"
++ | "robot.head_preset"
++ | "robot.headlight"
++ | "robot.say"
++ | "robot.macro"
++ | "chat.message"
++ | "chat.pin"
++ | "chat.poll"
++ | "screen.alert"
++ | "screen.animation"
++ | "screen.confetti"
++ | "screen.sound"
++ | "screen.tts"
++ | "screen.counter"
++ | "screen.state"
++ | "screen.visibility"
++ | "screen.scene"
++ | "stream.clip"
++ | "stream.moment"
++ | "stream.title"
++ | "stream.category"
++ | "ai.ask"
++ | "actor.task"
++ | "webhook.call"
++ | "money.tip_goal"
++ )[]
++ ];
++ /**
++ * The element's state before anything runs; the counter starts at 0, never at a value the element did not declare.
++ */
++ initial_state?: {
++ [k: string]: (boolean | number | string | (number | string)[]) | undefined;
++ };
++ /**
++ * A code module for an element whose recipe is not enough (stage 3). It runs sandboxed; the reference is data, and the host checks every request the module makes against the element's capabilities.
++ */
++ mod?: {
++ id: string;
++ version: string;
++ sha256: string;
++ /**
++ * The subset of the element's capabilities the module itself may request; omitted means the element's declared capabilities, and a product may grant fewer.
++ *
++ * @maxItems 32
++ */
++ capabilities?: (
++ | "robot.drive_pulse"
++ | "robot.head_preset"
++ | "robot.headlight"
++ | "robot.say"
++ | "robot.macro"
++ | "chat.message"
++ | "chat.pin"
++ | "chat.poll"
++ | "screen.alert"
++ | "screen.animation"
++ | "screen.confetti"
++ | "screen.sound"
++ | "screen.tts"
++ | "screen.counter"
++ | "screen.state"
++ | "screen.visibility"
++ | "screen.scene"
++ | "stream.clip"
++ | "stream.moment"
++ | "stream.title"
++ | "stream.category"
++ | "ai.ask"
++ | "actor.task"
++ | "webhook.call"
++ | "money.tip_goal"
++ )[];
++ };
++ created_at?: string;
++ updated_at?: string;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.element-state@1: the small store an Element keeps for itself (counters, scores, lists, a wheel's entries), as Live broadcasts it to everyone watching and as a read returns it. CHANNEL and ELEMENT_ID name the element instance, REVISION grows by one with each accepted change so a late frame never overwrites a newer one, and VALUES is the store itself: at most 32 keys, each a boolean, a number, a short string or a list of those. It is state, not code: no objects, no markup, no URL and no account data, which keeps a shared element's state safe to render for every viewer.
++ */
++export interface UiElementState {
++ /**
++ * The Live channel's username (its slug).
++ */
++ channel: string;
++ element_id: string;
++ revision: number;
++ /**
++ * The element's own store. A key is lower-case and 32 characters at most; a value is a boolean, a number, a string of 200 characters at most or a list of those.
++ */
++ values: {
++ [k: string]: (boolean | number | string | (number | string)[]) | undefined;
++ };
++ updated_at: string;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * ui.trigger@1: one trigger an Element's recipe listens for, from the fixed catalog. kind says what happened: a viewer pressing, holding or double-pressing the element, a key or gamepad binding, a chat command or text, a follow, a tip, an AI Moment, a Rundown topic, a timer or schedule, robot telemetry or an event, the stream starting or ending, a viewer-count crossing or another element's event. The fields a kind needs are the only fields it accepts. A trigger is data: it never names code or a URL, and a product may refuse a kind it does not offer yet. Stage 1 of Elements uses press and chat_command; the rest are declared here so a stage-2 element and the editor speak one language.
++ */
++export type UiTrigger =
++ | {
++ kind: "press";
++ }
++ | {
++ kind: "hold";
++ ms: number;
++ }
++ | {
++ kind: "double_press";
++ /**
++ * The longest gap between the two presses; 400 by default.
++ */
++ within_ms?: number;
++ }
++ | {
++ kind: "key";
++ key: string;
++ /**
++ * @maxItems 4
++ */
++ modifiers?:
++ | []
++ | ["ctrl" | "alt" | "shift" | "meta"]
++ | ["ctrl" | "alt" | "shift" | "meta", "ctrl" | "alt" | "shift" | "meta"]
++ | ["ctrl" | "alt" | "shift" | "meta", "ctrl" | "alt" | "shift" | "meta", "ctrl" | "alt" | "shift" | "meta"]
++ | [
++ "ctrl" | "alt" | "shift" | "meta",
++ "ctrl" | "alt" | "shift" | "meta",
++ "ctrl" | "alt" | "shift" | "meta",
++ "ctrl" | "alt" | "shift" | "meta"
++ ];
++ }
++ | {
++ kind: "gamepad";
++ button: string;
++ /**
++ * Which connected pad; omitted means any.
++ */
++ index?: number;
++ }
++ | {
++ kind: "chat_command";
++ /**
++ * The command without arguments, lower case, e.g. !dance.
++ */
++ command: string;
++ }
++ | {
++ kind: "chat_text";
++ /**
++ * @minItems 1
++ * @maxItems 16
++ */
++ words:
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * any (default) matches one of the words; all matches every one.
++ */
++ mode?: "any" | "all";
++ }
++ | {
++ kind: "follow";
++ }
++ | {
++ kind: "tip";
++ /**
++ * The smallest tip, in Vibes, that fires the trigger; omitted means any tip.
++ */
++ min?: number;
++ }
++ | {
++ kind: "ai_moment";
++ }
++ | {
++ kind: "rundown_topic";
++ /**
++ * Optional words the topic has to mention; omitted means any topic.
++ *
++ * @maxItems 8
++ */
++ words?:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string];
++ }
++ | {
++ kind: "timer";
++ every_seconds: number;
++ /**
++ * live (default) counts only while the channel is live; always counts from the element being active.
++ */
++ start?: "live" | "always";
++ }
++ | {
++ kind: "schedule";
++ /**
++ * A five-field cron in the person's timezone.
++ */
++ cron: string;
++ timezone: string;
++ }
++ | {
++ kind: "robot_telemetry";
++ metric: "battery_percent" | "latency_ms" | "stuck" | "temperature_c";
++ op: "lt" | "gt";
++ value: number;
++ /**
++ * How long the condition must hold; 0 (default) means at once.
++ */
++ for_seconds?: number;
++ }
++ | {
++ kind: "robot_event";
++ event: "estop" | "stopped" | "disconnected" | "command_refused" | "button";
++ }
++ | {
++ kind: "stream_start";
++ }
++ | {
++ kind: "stream_end";
++ }
++ | {
++ kind: "viewer_count";
++ op: "above" | "below";
++ count: number;
++ }
++ | {
++ kind: "element_event";
++ element_id: string;
++ /**
++ * pressed (default) fires after that element ran; state and shown fire when Live broadcasts them.
++ */
++ event?: "pressed" | "state" | "shown";
++ };
++
++/** [email protected] (owner: live) */
++/**
++ * live.element.pressed v1 (OpenVibe.Live server/elements): a viewer's element press was accepted and ran. It carries the public facts the stream should see: the channel, the element's id and name, the presser's public name and subject, the trigger that fired, the channel points it cost and the element-state revision it left behind. It never carries the presser's email, address, account balance or anything private; a refused press does not produce it. Envelope: subject { type: element, id: el_… }, actor the presser's subject, visibility public, priority low. Also written to the channel's AI timeline as a moment, so the stream's own timeline can cite 'chat made the robot dance'.
++ */
++export interface LiveElementPressedPayload {
++ /**
++ * The public channel facts (the channel page is public already).
++ */
++ channel: {
++ username: string;
++ display_name: string;
++ };
++ element_id: string;
++ element_name: string;
++ presser: SubjectRef;
++ /**
++ * The display name the stream shows, never an email or an account id.
++ */
++ presser_name: string;
++ trigger: UiTrigger;
++ /**
++ * Channel points the press cost; 0 when it was free.
++ */
++ points_spent?: number;
++ /**
++ * The element-state revision after the actions ran; 0 when the element has no state yet.
++ */
++ state_revision?: number;
++ at: string;
++}
++
++/** [email protected] (owner: live) */
++/**
++ * live.element.state v1 (OpenVibe.Live server/elements): an element's own store changed, broadcast to everyone watching through Live's existing events path. Carries the channel's public facts and the same store as ui.element-state@1: the element instance, a revision that grows by one per accepted change, the values themselves and when. It is public state (a counter, a score, a wheel's entries), never the presser's data: a value may not be an object, a markup string or a URL, and the payload carries no account.
++ */
++export interface LiveElementStatePayload {
++ channel: {
++ username: string;
++ display_name: string;
++ };
++ element_id: string;
++ revision: number;
++ /**
++ * The element's own store. A key is lower-case and 32 characters at most; a value is a boolean, a number, a string of 200 characters at most or a list of those.
++ */
++ values: {
++ [k: string]: (boolean | number | string | (number | string)[]) | undefined;
++ };
++ updated_at: string;
++}
++
++/** [email protected] (owner: ai) */
++/**
++ * A deep dive: expert design research run by OpenVibe.AI (workflow design.deep_dive) for OpenVibe.Exchange's /deep-dive page and, through the API, OpenVibe.Design. One document holds the question and depth (the input), the seven steps, every source read, every capture (a private Media object per viewport and theme, with its DOM facts and Expert extras), the per-site analysis, the synthesis across sites, the recommendations checked against OpenVibe's own rules, and the receipt of every model call and capture. Every claim carries its evidence: the site, the capture or crop, the URL and the date it was read. Nothing is copied: captures are private to the run's owner and the report shows them as attributed commentary.
++ */
++export interface AiDeepDive {
++ /**
++ * The run id; a deep dive is a run (GET /api/v1/deep-dives/{id}).
++ */
++ id: string;
++ schema: "ai.deep-dive@1";
++ run_id: string;
++ status: "queued" | "running" | "choosing" | "succeeded" | "failed" | "cancelled";
++ input: {
++ question: string;
++ depth: "quick" | "thorough" | "expert";
++ max_sites: number;
++ /**
++ * Sites the person named when starting the run.
++ *
++ * @maxItems 30
++ */
++ sites?: string[];
++ };
++ steps: {
++ key: "plan" | "search" | "choose" | "capture" | "analyse" | "synthesise" | "report";
++ status: "pending" | "running" | "done" | "skipped" | "failed";
++ detail?: string | null;
++ started_at?: string | null;
++ finished_at?: string | null;
++ }[];
++ sources: {
++ id: string;
++ kind: "web" | "picture" | "icon" | "project" | "rules" | "component" | "named";
++ title: string;
++ url: string;
++ publisher?: string | null;
++ engine?: string | null;
++ snippet?: string | null;
++ license?: string | null;
++ read_at: string;
++ }[];
++ captures: {
++ id: string;
++ source_id: string;
++ url: string;
++ final_url?: string | null;
++ title?: string | null;
++ captured_at: string;
++ viewport: 390 | 1280;
++ theme: "light" | "dark";
++ full_page: boolean;
++ media_id?: string | null;
++ width?: number | null;
++ height?: number | null;
++ crops: {
++ id: string;
++ media_id: string;
++ y: number;
++ height: number;
++ }[];
++ dom?: {} | null;
++ extras?: {} | null;
++ /**
++ * Why the capture did not happen: robots_disallowed, not_public, no_chrome, timeout, …
++ */
++ skip?: string | null;
++ cached?: boolean;
++ }[];
++ analysis: {
++ source_id: string;
++ url: string;
++ summary: string;
++ first_impression?: string;
++ hierarchy?: string;
++ density?: string;
++ type?: string;
++ colour?: string;
++ motion?: string;
++ words?: string;
++ /**
++ * @maxItems 12
++ */
++ strengths:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string];
++ /**
++ * @maxItems 12
++ */
++ weaknesses:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string];
++ accessibility?: string | null;
++ speed?: string | null;
++ evidence: {
++ claim: string;
++ source_id?: string | null;
++ capture_id?: string | null;
++ crop_id?: string | null;
++ url: string;
++ read_at: string;
++ }[];
++ }[];
++ synthesis: {
++ patterns: {
++ name: string;
++ what: string;
++ /**
++ * @maxItems 30
++ */
++ sites: string[];
++ usage_count?: number;
++ evidence: {
++ claim: string;
++ source_id?: string | null;
++ capture_id?: string | null;
++ crop_id?: string | null;
++ url: string;
++ read_at: string;
++ }[];
++ }[];
++ comparison: {
++ /**
++ * @maxItems 12
++ */
++ columns:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string];
++ rows:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string][];
++ };
++ best: {
++ source_id: string;
++ what: string;
++ evidence: {
++ claim: string;
++ source_id?: string | null;
++ capture_id?: string | null;
++ crop_id?: string | null;
++ url: string;
++ read_at: string;
++ }[];
++ };
++ mistakes: {
++ what: string;
++ evidence: {
++ claim: string;
++ source_id?: string | null;
++ capture_id?: string | null;
++ crop_id?: string | null;
++ url: string;
++ read_at: string;
++ }[];
++ }[];
++ gap: string;
++ reference_tokens: {
++ palettes: {
++ source_id: string;
++ /**
++ * @maxItems 16
++ */
++ colours:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ }[];
++ type_scales: {
++ source_id: string;
++ /**
++ * @maxItems 8
++ */
++ families:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string];
++ /**
++ * @maxItems 16
++ */
++ sizes:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ }[];
++ spacing: {
++ source_id: string;
++ /**
++ * @maxItems 16
++ */
++ steps:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ }[];
++ };
++ /**
++ * @maxItems 20
++ */
++ do:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * @maxItems 20
++ */
++ dont:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * @maxItems 20
++ */
++ accessibility:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ /**
++ * @maxItems 20
++ */
++ speed:
++ | []
++ | [string]
++ | [string, string]
++ | [string, string, string]
++ | [string, string, string, string]
++ | [string, string, string, string, string]
++ | [string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ]
++ | [
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string,
++ string
++ ];
++ };
++ recommendations: {
++ id: string;
++ what: string;
++ why: string;
++ rule: {
++ id?: string | null;
++ status: "follows" | "would_change" | "none";
++ text: string;
++ };
++ evidence: {
++ claim: string;
++ source_id?: string | null;
++ capture_id?: string | null;
++ crop_id?: string | null;
++ url: string;
++ read_at: string;
++ }[];
++ }[];
++ receipt: {
++ price_usd: number;
++ held_usd: number;
++ cost_usd: number;
++ captures_count: number;
++ synthetic: boolean;
++ models: {
++ step: "plan" | "choose" | "analyse" | "synthesise";
++ provider: string | null;
++ model: string | null;
++ tokens_in: number;
++ tokens_out: number;
++ cost_usd: number;
++ ms: number;
++ }[];
++ };
++ /**
++ * @maxItems 40
++ */
++ gaps: string[];
++ created_at: string;
++ updated_at: string;
++ finished_at?: string | null;
++}
++
++/** [email protected] (owner: ai) */
++/**
++ * ai.deep_dive.updated v1 (OpenVibe.AI server/deep-dive): a deep dive's step changed or it ended. One event per step transition and one at the end, so a page (OpenVibe.Exchange's /deep-dive) can follow a run without polling, and OpenVibe.Design can attach a finished report. The payload carries the run and deep-dive ids, the step, the progress and the status; never the question's answer, a capture or a prompt. Envelope: subject { type: run, id: <run_id> }, visibility internal, priority low (important on failed), actor service:ai.
++ */
++export interface AiDeepDiveUpdatedPayload {
++ deep_dive_id: string;
++ run_id: string;
++ status: "queued" | "running" | "choosing" | "succeeded" | "failed" | "cancelled";
++ step: "plan" | "search" | "choose" | "capture" | "analyse" | "synthesise" | "report" | "done" | null;
++ progress: number;
++ steps: {
++ key: "plan" | "search" | "choose" | "capture" | "analyse" | "synthesise" | "report";
++ status: "pending" | "running" | "done" | "skipped" | "failed";
++ }[];
++ depth: "quick" | "thorough" | "expert";
++ requester: SubjectRef;
++ on_behalf_of: SubjectRef | null;
++ url: string;
++ error: {
++ code: string;
++ detail: string;
++ } | null;
++ occurred_at: string;
++}
ddiff --git a/manifests/capabilities/ai.layout.suggest.json b/manifests/capabilities/ai.layout.suggest.json
nnew file mode 100644
iindex 00000000..8e06ddd1
---- /dev/null
++++ b/manifests/capabilities/ai.layout.suggest.json
@@@ -0,0 +1,21 @@
++{
++ "id": "ai.layout.suggest",
++ "version": "1.0",
++ "owner": "ai",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Turn a person's words into a ui.layout@1 for one surface, using only the parts its ui.surface@1 catalog has. The answer is checked by openvibe-sdk/ui-layout against the same catalog; an invalid answer gets one retry with the validator's reasons, then the task says plainly that it could not do it. The suggestion is previewed, never applied. Runs on the cheap structured-output route with the per-person limits every AI run uses, and its receipt names the model. POST /api/v1/ui/layout/suggest with a service token; OpenVibe.Network calls it for the signed-in person and names them in X-OV-Subject.",
++ "permissions": [
++ "ai:layout"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ai-run",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/ui/layout/suggest"
++ ],
++ "inputSchema": "ui.layout-request@1",
++ "outputSchema": "ui.layout-suggestion@1"
++}
ddiff --git a/manifests/capabilities/network.layout.read.json b/manifests/capabilities/network.layout.read.json
nnew file mode 100644
iindex 00000000..e674c522
---- /dev/null
++++ b/manifests/capabilities/network.layout.read.json
@@@ -0,0 +1,21 @@
++{
++ "id": "network.layout.read",
++ "version": "1.0",
++ "owner": "network",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read the active ui.layout@1 one person made for a product's own surface, with that surface's device class. A first-party service presents a service token for openvibe.network and names the person with X-OV-Subject (which must be the person in the path); it reads one layout and nothing else of their account, and never their saved or gallery layouts. The person's own token uses GET /api/v1/me/layouts/:surface instead, which reads and writes only their own rows.",
++ "permissions": [
++ "network:layout"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "standard",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/people/:subject/layouts/:surface"
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "ui.layout@1"
++}
ddiff --git a/manifests/services/ai.json b/manifests/services/ai.json
iindex 2c6aeb04..768ce3dd 100644
---- a/manifests/services/ai.json
++++ b/manifests/services/ai.json
@@@ -36,6 +36,7 @@
"ai.memory.write",
"ai.me.read",
"ai.me.manage",
++ "ai.layout.suggest",
"ai.config.manage",
"exchange.search.icons"
],
@@@ -45,7 +46,8 @@
"ai.run.succeeded",
"ai.run.failed",
"ai.run.cached",
-- "ai.credential.failed"
++ "ai.credential.failed",
++ "ai.deep_dive.updated"
],
"eventsConsumed": [],
"namespacesOwned": [
ddiff --git a/manifests/services/live.json b/manifests/services/live.json
iindex fcd443fc..51a67cec 100644
---- a/manifests/services/live.json
++++ b/manifests/services/live.json
@@@ -46,7 +46,12 @@
],
"neighbourhood": "watch",
"strip": [
-- { "label": "The Rundown", "icon": "news", "link": "https://openvibe.live/rundown", "line": "What streamers talked about" }
++ {
++ "label": "The Rundown",
++ "icon": "news",
++ "link": "https://openvibe.live/rundown",
++ "line": "What streamers talked about"
++ }
]
},
"capabilities": [
@@@ -69,6 +74,8 @@
],
"eventsProduced": [
"live.agent_stream.changed",
++ "live.element.pressed",
++ "live.element.state",
"live.index_document.deleted",
"live.index_document.upserted",
"live.moderation.action",
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex b2bc60cd..c347b939 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -93,7 +93,8 @@
"network.levels.award",
"network.link.access.request",
"network.agents.register",
-- "network.agents.relay"
++ "network.agents.relay",
++ "network.layout.read"
],
"eventsProduced": [
"network.account.deleted",
@@@ -120,6 +121,7 @@
"network.machine.paired",
"network.machine.roles_changed",
"network.machine.revoked",
++ "network.layout.changed",
"share.granted",
"share.revoked",
"share.transfer.requested",
ddiff --git a/package-lock.json b/package-lock.json
iindex 677af419..09b1e02c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-contracts",
-- "version": "0.409.0",
++ "version": "0.410.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-contracts",
-- "version": "0.409.0",
++ "version": "0.410.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex b11d4963..8e9189a2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.409.0",
++ "version": "0.410.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
ddiff --git a/test/elements.test.js b/test/elements.test.js
nnew file mode 100644
iindex 00000000..7805bd54
---- /dev/null
++++ b/test/elements.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++// Elements (owner ask 2026-11-11): the four contracts and two events that make a button people
++// make. JSON Schema holds most of it; these are the rules it cannot state, and the drift between
++// the one action catalog and the capabilities an element declares.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('..');
++
++const ROOT = path.join(__dirname, '..');
++let n = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
++const fixture = (id, kind, file) => JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures', id, kind, file), 'utf8'));
++
++// ── The action catalog is one list ───────────────────────────────────────
++const actions = contracts.schema('ui.action@1');
++const variants = actions.oneOf;
++const kinds = variants.map((v) => v.properties.kind.const);
++ok(new Set(kinds).size === kinds.length, 'every action kind appears once');
++const capability = actions.$defs.capability.enum;
++ok(JSON.stringify([...capability].sort()) === JSON.stringify([...kinds].sort()),
++ 'ui.action.$defs.capability is exactly the action catalog (an element cannot declare a kind no action has)');
++// The product that authorises each kind is fixed by the catalog, not chosen by the element.
++const PRODUCT = {
++ 'robot.drive_pulse': 'bot', 'robot.head_preset': 'bot', 'robot.headlight': 'bot', 'robot.say': 'bot', 'robot.macro': 'bot',
++ 'chat.message': 'chat', 'chat.pin': 'chat', 'chat.poll': 'chat',
++ 'screen.alert': 'live', 'screen.animation': 'live', 'screen.confetti': 'live', 'screen.sound': 'live',
++ 'screen.tts': 'live', 'screen.counter': 'live', 'screen.state': 'live', 'screen.visibility': 'live',
++ 'screen.scene': 'live', 'stream.clip': 'live', 'stream.moment': 'live', 'stream.title': 'live',
++ 'stream.category': 'live', 'webhook.call': 'live',
++ 'ai.ask': 'ai', 'actor.task': 'actor', 'money.tip_goal': 'billing',
++};
++ok(Object.keys(PRODUCT).length === kinds.length, 'the product map covers every action kind');
++for (const v of variants) {
++ const kind = v.properties.kind.const;
++ ok(v.properties.product.const === PRODUCT[kind], `${kind} is authorised by ${PRODUCT[kind]}`);
++}
++
++// ── The trigger catalog is one list ──────────────────────────────────────
++const triggers = contracts.schema('ui.trigger@1').oneOf;
++const triggerKinds = triggers.map((v) => v.properties.kind.const);
++ok(new Set(triggerKinds).size === triggerKinds.length, 'every trigger kind appears once');
++ok(triggerKinds.includes('press') && triggerKinds.includes('chat_command'),
++ 'stage 1\'s triggers (press, chat_command) are in the catalog');
++
++// ── An element's recipe may only use what it declared ────────────────────
++// JSON Schema can compare a capability with an action only inside one document; this is the
++// cross-field rule openvibe-sdk/elements' validate() enforces too, checked here on the fixtures.
++for (const f of fs.readdirSync(path.join(ROOT, 'fixtures/ui.element/valid')).filter((x) => x.endsWith('.json'))) {
++ const el = fixture('ui.element', 'valid', f);
++ const declared = new Set(el.capabilities);
++ for (const a of el.recipe.actions) ok(declared.has(a.kind), `${f}: ${a.kind} is declared`);
++ if (el.mod && el.mod.capabilities) for (const c of el.mod.capabilities) ok(declared.has(c), `${f}: the mod may request only ${c} if the element declared it`);
++ const text = JSON.stringify(el);
++ ok(!/https?:\/\/|<[a-z!/]/i.test(text), `${f}: an element carries no URL or markup`);
++ const state = el.initial_state || {};
++ for (const [k, v] of Object.entries(state)) {
++ ok(/^[a-z][a-z0-9_]{0,31}$/.test(k), `${f}: state key ${k} is lower-case`);
++ ok(v === null || typeof v !== 'object' || Array.isArray(v), `${f}: state ${k} is a value, not an object`);
++ }
++}
++
++// ── The store is state, not data that can run ────────────────────────────
++const state = contracts.schema('ui.element-state@1');
++ok(!contracts.validate('ui.element-state@1', { channel: 'ada', element_id: 'el_01JAB2C3D4E5F6G7H8J9K0MNPQ', revision: 0, values: { wheel: { a: 1 } }, updated_at: '2026-11-11T20:00:00.000Z' }).valid,
++ 'a state value may not be an object');
++ok(!contracts.validate('ui.element-state@1', { channel: 'ada', element_id: 'el_01JAB2C3D4E5F6G7H8J9K0MNPQ', revision: 0, values: { ok: 'x'.repeat(201) }, updated_at: '2026-11-11T20:00:00.000Z' }).valid,
++ 'a state string is at most 200 characters (the product content-checks the text it shows)');
++ok(state.properties.values.$ref === '#/$defs/values', 'ui.element-state@1 values are the shared values definition');
++
++// ── The events carry the public facts only ───────────────────────────────
++const pressed = contracts.schema('live.element.pressed@1');
++for (const k of ['channel', 'element_id', 'element_name', 'presser', 'presser_name', 'trigger', 'at']) {
++ ok(pressed.required.includes(k), `live.element.pressed carries ${k}`);
++}
++ok(!('email' in pressed.properties) && !('ip' in pressed.properties) && !('balance' in pressed.properties),
++ 'live.element.pressed carries no private account data');
++const changed = contracts.schema('live.element.state@1');
++ok(JSON.stringify(changed.required) === JSON.stringify(['channel', 'element_id', 'revision', 'values', 'updated_at']),
++ 'live.element.state names the store and its revision');
++for (const id of ['live.element.pressed', 'live.element.state']) {
++ const entry = contracts.catalog.find((c) => c.id === id);
++ ok(entry && entry.owner === 'live', `${id} is owned by Live`);
++}
++
++console.log(`elements: ${n} checks passed`);
ddiff --git a/test/run.js b/test/run.js
iindex 395afd42..f3669f70 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -42,6 +42,7 @@ sub(path.join(ROOT, 'test/compare-comparison.test.js'));
sub(path.join(ROOT, 'test/link-brand.test.js'));
sub(path.join(ROOT, 'test/link-compute.test.js'));
sub(path.join(ROOT, 'test/money-reasons.test.js'));
++sub(path.join(ROOT, 'test/elements.test.js'));
// ── Catalog integrity ────────────────────────────────────────────────────
const files = [];
@@@ -141,6 +142,33 @@ ok(contracts.resolve('ai.credential-put@1').schema === 'ai/credential-put.v1.jso
&& contracts.resolve('ai.credential-put').version === '2.0.0', 'a contract with two majors resolves by id (newest) or by major');
assert.throws(() => contracts.assertValid('identity.subject-ref', { type: 'user', id: '1' }), e => e.code === 'contract.invalid');
++// ── UI surfaces and layouts (make your own controls, 2026-11-11) ─────────
++// ui.surface@1 is the catalog a product publishes; ui.layout@1 arranges only what it has. The
++// catalog's own consistency is not something JSON Schema can state: a part's tint has to be one of
++// the surface's theme tokens, part ids are unique, and a never_covers target is a real part.
++{
++ const surface = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/ui.surface/valid/bot-controls.json'), 'utf8'));
++ const partIds = new Set(surface.parts.map((p) => p.id));
++ ok(partIds.size === surface.parts.length, 'ui.surface part ids are unique');
++ for (const p of surface.parts) {
++ for (const tint of p.styles.tints) ok(surface.theme_tokens.includes(tint), `${surface.id}/${p.id} tint ${tint} is a theme token`);
++ for (const target of p.never_covers || []) ok(partIds.has(target), `${surface.id}/${p.id} never_covers names ${target}`);
++ const cols = surface.grid.columns.phone;
++ ok(p.min.w <= cols, `${surface.id}/${p.id} minimum fits the phone grid`);
++ if (p.pressed) ok(p.min.w * surface.grid.cell_px.phone >= 44 && p.min.h * surface.grid.cell_px.phone >= 44, `${surface.id}/${p.id} pressed minimum is at least 44 px`);
++ }
++ // The layout is data only: no URL or markup may appear in a value a layout stores.
++ const layoutText = fs.readFileSync(path.join(ROOT, 'fixtures/ui.layout/valid/phone-controls.json'), 'utf8');
++ ok(!/https?:\/\/|<[a-z]/i.test(layoutText), 'ui.layout carries no URL or markup');
++ // A suggestion says what it could not do rather than carrying an unchecked layout.
++ const suggestion = contracts.validate('ui.layout-suggestion@1', JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/ui.layout-suggestion/valid/suggestion.json'), 'utf8')));
++ ok(suggestion.valid, 'ui.layout-suggestion accepts a validated arrangement with its reasons and receipt');
++ ok(!contracts.validate('ui.layout-suggestion@1', { ok: true, layout: null, summary: 'x', reasons: [] }).valid, 'a successful suggestion must carry a layout');
++ // The event names the change and the layout revision; no account data is in the payload.
++ const changed = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/network.layout.changed/valid/activated.json'), 'utf8'));
++ ok(!('subject' in changed) && !('author' in changed) && !('parts' in changed), 'network.layout.changed carries no account data or layout contents');
++}
++
// ── Manifests ────────────────────────────────────────────────────────────
const serviceIds = new Set(services.manifests.map(m => m.id));
for (const m of services.manifests) {