---
title: "frame.menu-card@1 · OpenVibe.Services"
canonical: "https://openvibe.services/docs/contracts/frame.menu-card"
updated: null
---

# frame.menu-card@1 · OpenVibe.Services

HTML: [frame.menu-card@1 · OpenVibe.Services](https://openvibe.services/docs/contracts/frame.menu-card)

# FrameMenuCard `frame.menu-card@1`

Generated at 2026-11-13 06:25:48 UTC from [`openvibe-contracts v0.419.0`](https://github.com/OpenVibers/OpenVibe.Contracts/tree/v0.419.0) and [`openvibe-sdk v0.161.0`](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.161.0).

- Version: 1.0.0
- Owner: network
- Visibility: public
- Status: active
- Compatibility: backward
- Schema: [`https://openvibe.network/contracts/frame/menu-card.v1.json`](/docs/contracts/frame.menu-card.json)

frame.menu-card@1: one card a site publishes for the network's Frame menu. Each open site serves an array of at most three cards at /.well-known/openvibe-menu.json (frame.menu-cards@1); Network fetches that public file every five minutes, validates every card here, strips markup from the text and ranks the cards in GET /api/frame. A card is one kind of menu block: a rail (the live rail), rows (a ranked list) or chips (a short row of words). Every item links to https on the site's own origin or an openvibe.\* host; Network refuses another origin. An item may also carry a small picture (image) of the same shape: https on the site's own origin or an openvibe.\* host, with an optional 16–1920 pixel width and height and a short alt text. The text is plain words: markup is stripped, unknown fields are dropped and a file that fails this schema leaves the site's last good card in place for 30 minutes.

## Fields

| Field | Type | Required | Description | Constraints |
| --- | --- | --- | --- | --- |
| `id` | string | yes | The card's stable id in its site (live-now, new-worlds, trending). Two cards of one site never share it. | - `pattern ^[a-z][a-z0-9-]{1,39}$` |
| `site` | string | yes | The Frame site id that publishes the card (live, games, media-hub). Network checks it against the host the file came from. | - `pattern ^[a-z][a-z0-9-]{1,39}$` |
| `title` | string | yes | The card's heading, in the owner's words. | - `minLength 1`<br>- `maxLength 60` |
| `kind` | enum | yes | The block the Frame draws: a rail of live channels, a ranked list of rows, or a short row of chips. | - `one of "rail", "rows", "chips"` |
| `items` | array of object | yes | The card's entries, in the site's own order. Empty means the site has a card but nothing to show (nobody is live); Network then skips it. | - `maxItems 6`<br>- `items: no other fields` |
| `items[].title` | string | yes | The entry's name, plain words. | - `minLength 1`<br>- `maxLength 80` |
| `items[].meta` | string |  | One short line under the name: "12 watching", "7,412 views this week", "3 players". | - `maxLength 120` |
| `items[].url` | string | yes | Where the entry opens: https on the publishing site's own origin or an openvibe.\* host. | - `pattern ^https://`<br>- `format uri`<br>- `maxLength 400` |
| `items[].image` | object |  | A small picture of the entry: a video's thumbnail, a world's cover or a stream's preview. Optional; a site with no real picture leaves it out rather than sending a placeholder. | - `no other fields` |
| `items[].image.src` | string | yes | The picture's address: https on the publishing site's own origin or an openvibe.\* host. Network drops an image from another host. | - `pattern ^https://`<br>- `format uri`<br>- `maxLength 400` |
| `items[].image.w` | integer |  | The picture's natural width in pixels, when the site knows it. | - `minimum 16`<br>- `maximum 1920` |
| `items[].image.h` | integer |  | The picture's natural height in pixels, when the site knows it. | - `minimum 16`<br>- `maximum 1920` |
| `items[].image.alt` | string |  | A short description of the picture for anyone who cannot see it. May be empty when the entry's title already says it. | - `maxLength 120` |
| `more` | object |  |  | - `no other fields` |
| `more.label` | string | yes | The link's words ("See all", "More worlds"). | - `minLength 1`<br>- `maxLength 24` |
| `more.url` | string | yes | https on the publishing site's own origin or an openvibe.\* host. | - `pattern ^https://`<br>- `format uri`<br>- `maxLength 400` |
| `updated_at` | string | yes | When the site last built the card. | - `format date-time` |
| `ttl_s` | integer | yes | How long the site considers the card fresh, in seconds. Live uses 30; Network itself refetches every five minutes. | - `minimum 30`<br>- `maximum 86400` |

## Examples

From the contract's own test fixtures: valid ones validate, rejected ones must fail.

 Valid: empty-rail

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Valid: live-now

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [
    {
      "title": "Nova",
      "meta": "12 watching",
      "url": "https://openvibe.live/@nova",
      "image": {
        "src": "https://openvibe.live/t/nova.jpg?w=640",
        "w": 640,
        "h": 360,
        "alt": "Nova at the desk"
      }
    },
    {
      "title": "Kitchen radio",
      "meta": "3 watching",
      "url": "https://openvibe.live/@kitchen"
    }
  ],
  "more": {
    "label": "See all",
    "url": "https://openvibe.live/live"
  },
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Valid: new-worlds

~~~~
{
  "id": "new-worlds",
  "site": "games",
  "title": "New worlds",
  "kind": "rows",
  "items": [
    {
      "title": "Harbour Town",
      "meta": "Built by 4 people",
      "url": "https://openvibe.games/w/harbour-town",
      "image": {
        "src": "https://openvibe.games/worlds/harbour-town/cover.jpg",
        "w": 1200,
        "h": 675
      }
    },
    {
      "title": "Night Market",
      "meta": "18 visitors this week",
      "url": "https://openvibe.games/w/night-market"
    }
  ],
  "updated_at": "2026-11-07T07:58:00Z",
  "ttl_s": 300
}
~~~~

Rejected: bad-kind

~~~~
{
  "id": "trending",
  "site": "media-hub",
  "title": "Trending",
  "kind": "grid",
  "items": [],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 300
}
~~~~

Rejected: http-image

~~~~
{
  "id": "trending",
  "site": "media-hub",
  "title": "Trending",
  "kind": "rows",
  "items": [
    {
      "title": "A walk",
      "url": "https://openvibe.video/v/1",
      "image": {
        "src": "http://openvibe.media/t/1.jpg",
        "w": 640,
        "h": 360
      }
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 300
}
~~~~

Rejected: http-url

~~~~
{
  "id": "trending",
  "site": "media-hub",
  "title": "Trending",
  "kind": "rows",
  "items": [
    {
      "title": "A clip",
      "meta": "2,100 views",
      "url": "http://openvibe.video/watch/abc"
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 300
}
~~~~

Rejected: image-alt-too-long

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [
    {
      "title": "Nova",
      "url": "https://openvibe.live/@nova",
      "image": {
        "src": "https://openvibe.live/t/nova.jpg",
        "alt": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
      }
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Rejected: image-no-src

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [
    {
      "title": "Nova",
      "url": "https://openvibe.live/@nova",
      "image": {
        "w": 640,
        "h": 360
      }
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Rejected: image-unknown-field

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [
    {
      "title": "Nova",
      "url": "https://openvibe.live/@nova",
      "image": {
        "src": "https://openvibe.live/t/nova.jpg",
        "credit": "Nova"
      }
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Rejected: image-width-too-small

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [
    {
      "title": "Nova",
      "url": "https://openvibe.live/@nova",
      "image": {
        "src": "https://openvibe.live/t/nova.jpg",
        "w": 8,
        "h": 360
      }
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Rejected: seven-items

~~~~
{
  "id": "trending",
  "site": "media-hub",
  "title": "Trending",
  "kind": "rows",
  "items": [
    {
      "title": "One",
      "url": "https://openvibe.video/one"
    },
    {
      "title": "Two",
      "url": "https://openvibe.video/two"
    },
    {
      "title": "Three",
      "url": "https://openvibe.video/three"
    },
    {
      "title": "Four",
      "url": "https://openvibe.video/four"
    },
    {
      "title": "Five",
      "url": "https://openvibe.video/five"
    },
    {
      "title": "Six",
      "url": "https://openvibe.video/six"
    },
    {
      "title": "Seven",
      "url": "https://openvibe.video/seven"
    }
  ],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 300
}
~~~~

Rejected: title-too-long

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now, and everything happening across the whole network tonight",
  "kind": "rail",
  "items": [],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 30
}
~~~~

Rejected: ttl-too-short

~~~~
{
  "id": "live-now",
  "site": "live",
  "title": "Live now",
  "kind": "rail",
  "items": [],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 5
}
~~~~

Rejected: unknown-field

~~~~
{
  "id": "trending",
  "site": "media-hub",
  "title": "Trending",
  "kind": "rows",
  "items": [],
  "updated_at": "2026-11-07T08:00:00Z",
  "ttl_s": 300,
  "html": "<script>alert(1)</script>"
}
~~~~

## Validate

~~~~
const contracts = require('openvibe-contracts');
contracts.validate('frame.menu-card@1', value);   // { valid, errors: [{ path, message }] }
~~~~
