# Velven SDK

One small script a space loads to sign players in and keep a hosted leaderboard, inside the Velven frame, with no client id, secret or callback: being listed is the registration. Velven draws no board. It hands the rows back and the space draws its own, in its own style. This guide is written for the creator's coding agent as much as for the creator.

## 1. Add the script

A script tag with no build step, since most spaces are single pages written by an agent, or the npm package for the ones that bundle. Both set up the same `Velven` object with the same surface, inside and outside the frame.

```html
<script src="https://velven.ai/sdk/v1.js"></script>
```

```bash
npm install @velven/sdk
# or: bun add @velven/sdk
```

```js
import { Velven } from "@velven/sdk";
```

If your page sends its own Content-Security-Policy, its `script-src` must allow `https://velven.ai` for the script tag to load. The npm package needs nothing, since it is part of your bundle.

The script has no dependencies, holds nothing in localStorage, and never throws for an outcome: every call resolves to a result whose `ok` says whether it worked. Only a programming mistake, such as a value that is not a number, throws.

## 2. Declare your boards

Boards are declared by you, in the page, in a `<script type="application/velven+json">` block beside the proof tag in the `<head>`. Velven reads it when the space is listed, on its background check, and when you press Check now or Sync now on the edit page. No board is implicit: a score posted to a key you never declared is refused with `no_board`. Up to 10 boards per space.

```html
<meta name="velven" content="@handle">
<script type="application/velven+json">
{"boards":[{"key":"main","trust":"server","metric":"time","sort":"asc","cooldown":10}]}
</script>
```

Every field but `key` has a default, so the smallest block is `{"boards":[{"key":"main"}]}`. The fields:

| Field | Values | Meaning |
| --- | --- | --- |
| `key` | 1 to 32 lowercase letters, digits, `-` or `_` | The name the SDK calls the board by. `main` is what a call uses when it names none. |
| `trust` | `server`, `client`, default `server` | `server`: only your own server posts, with the secret and the player's token. `client`: the frame posts under the range, cooldown and caps, and every read says so. |
| `metric` | `points`, `time`, `distance`, `level`, `custom`, default `points` | What the value is. `custom` with a `label` and `unit` for anything else. |
| `label` | up to 40 characters | How you name the board when you draw it. Optional. |
| `unit` | up to 16 characters | Shown after the value when you draw it. Optional. |
| `sort` | `desc`, `asc`, default `desc` | `desc`: higher is better. `asc`: lower is better, for a time. Changeable any time. |
| `mode` | `best`, `sum`, default `best` | `best`: a player's best submission ranks. `sum`: their submissions add up. Changeable any time. |
| `min`, `max` | numbers, decimals allowed | The range a submission must be in. Outside it is `out_of_range`. |
| `cooldown` | 0 to 86400 seconds, default 0 | The least time between two submissions from one player. |
| `period` | `all`, `daily`, `weekly`, default `all` | `daily` and `weekly` roll at 00:00 UTC, weeks from Monday; every past bucket stays readable. |
| `season` | up to 32 characters | A name for a fresh start. A change of `metric` needs a new one. Every past season stays readable. |

Every submission is kept, and rankings are computed from a per-player summary, so `sort` and `mode` change without a reset and a rule change never rewrites a ranking. Velven never clears a board: `period` rolls the automatic buckets and `season` is your own reset. A board your block stops naming is withdrawn, its scores kept, and comes back when named again.

To change boards without a deploy, the same shape goes to the agent API on the creator's bearer token, only the boards named being written:

```bash
curl -s -X PUT https://velven.ai/api/spaces/<slug>/boards \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"boards":[{"key":"main","trust":"server","metric":"time","sort":"asc","cooldown":10}]}'
```

The edit page shows the boards and where each came from, the page or the API, and edits nothing.

## 3. Sign players in

Being signed in to Velven is the grant: there is no consent step. Inside the frame, a signed-in visitor's identity is known to your space the moment the SDK is ready, without a prompt. A signed-out visitor is asked only when your space asks, from a button, never on load.

```js
const environment = await Velven.ready(); // "velven" | "site" | "local"
Velven.environment;                       // the same, null before ready
Velven.user;                              // { id, handle, avatar } or null, for display only

// From a button. Inside the frame a signed-out visitor sees Velven's sign-in card over your space,
// signs in without your page reloading, and the promise resolves once they have, or once they chose to stay a guest.
const result = await Velven.signIn();
if (result.ok) console.log(result.user.handle, result.token, result.expiresAt);
else console.log(result.error); // "signed_out" | "unavailable" | "rate_limited" | "failed"

// Never shows anything: the token if the visitor is signed in, signed_out otherwise.
const quiet = await Velven.signIn({ silent: true });

// Every grant, silent or from the card, including the one at ready.
const stop = Velven.onAuth((user) => { /* draw the name */ });
```

- `ready()` resolves once and never rejects. In a top-level window it resolves at once; in a frame it waits up to seven seconds for the player and, on a claimed space, up to seven more for the first silent ask, so it is never later than fourteen seconds and usually under one.
- `ready()` resolves once the player has answered and, in the `velven` environment, once that first silent ask has landed, so `Velven.user` is set by then for a signed-in visitor and a title screen can show the name without a call.
- A grant is cached: `signIn` answers from memory while more than a minute of the hour is left, then asks again. The token is never written to storage.
- Ask from a button. Right after a visitor has chosen to stay a guest, a plain `signIn()` within five seconds is answered `signed_out` quietly, since no click could have asked for it; a later one shows the card again, until the visitor has stayed a guest twice on one page load, after which no card comes back and every plain ask answers `signed_out`.
- Only a published space with a verified owner can sign anyone in. An unclaimed import gets `unavailable`.
- The token is for your own server, not for the frame: see the server tier. A space without a backend never touches it.

The token is held in memory by the script and never written to storage, but `Velven` is a global on your page: any script your page loads can call `signIn({ silent: true })` and read it. It opens nothing but your own boards, and only with your secret beside it, so keep third-party scripts off a page that handles it all the same.

## 4. Post and read scores

A score is posted to one of the space's boards. Inside the frame the SDK hands it to the Velven page, which posts it under the visitor's own session, so nothing on the wire from your space is a credential. Reads come back the same way as rows. Velven draws no board: draw your own from these rows.

```js
const result = await Velven.scores.submit(1240, { board: "main", meta: { car: "red" } });
if (result.ok) {
  // { ok: true, board, rank, value, total, improved }
  // value is this submission; total is what the board ranks the player by now: their best, or their sum.
} else {
  // { ok: false, error, retryAfter? }
  if (result.error === "signed_out") { /* offer signIn() and send again */ }
}

const top = await Velven.scores.top({ board: "main", limit: 10 });
// { ok: true, board, trust: "server" | "client", rows }
// row: { rank, value, meta, setAt, user: { id, handle, avatar } }
const next = top.ok ? await Velven.scores.top({ board: "main", limit: 10, after: top.rows.at(-1) }) : top;

const mine = await Velven.scores.around({ board: "main", each: 5 }); // the rows around the signed-in visitor
```

- `board` defaults to `main` on every call. `meta` is an object of up to 1 KB as JSON, returned untouched by every read; a value that is not a number, or a `meta` that is not an object, is a programming mistake and throws a `TypeError`.
- `top` returns up to `limit` rows, 1 to 100, 10 by default; the next page is `top({ after: rows.at(-1) })`. Ties go to the earlier submission.
- `around` returns `each` rows either side of the visitor, 0 to 50, 5 by default, and needs a signed-in visitor.
- Every read carries `trust`. Show it: a client board is only as honest as a client can be.

The codes a score call can answer with:

Rows and `meta` are other players' data: a signed-in account can put any JSON up to 1 KB in `meta`. Draw them as text (`textContent`, never `innerHTML`) and never merge a row into your own state, or one player's entry can run in every other player's browser.

| Code | Meaning |
| --- | --- |
| `signed_out` | No visitor is signed in, or they chose to stay a guest. Ask with `signIn()` from a button and send again. |
| `unavailable` | Not in the Velven frame, or the space is not published and claimed. Nothing to fix in the space. |
| `no_board` | No board with that key. Declare it in the block or through the agent API. |
| `server_only` | The board's trust is `server`: only the space's own server may post, with the secret. |
| `out_of_range` | The value is under the board's `min` or over its `max`. |
| `cooldown` | Too soon after this player's last submission. `retryAfter` is the wait in seconds. |
| `banned` | The creator banned this player from the space's boards. |
| `invalid_value` | `meta` is over 1 KB. Answered before anything is sent. |
| `rate_limited` | Too many calls in a minute. Wait; do not retry in a loop. |
| `failed` | Velven could not answer just now. Try once more later. |

## 5. The server tier

A board is honest to the degree its scores come from a server, so `server` is the default trust. Your own server verifies the player's token, computes or checks the score, and posts it with the space's secret, which lives in your host's environment and never in the page. The frame cannot post to a server board at all.

The server can be anywhere. Velven checks the secret and the token's space claim, never where the post came from, so a space on a static host with no functions of its own, a ChatGPT site or GitHub Pages, still runs a server board by posting to a small function hosted elsewhere, on Vercel, Netlify, Cloudflare or Replit. That function needs one CORS line allowing your page's origin, since the page calls it cross-origin. Without any server, declare the board `client`.

Issue the secret once, on the edit page or through the agent API. It is shown once; only its hash is kept, and issuing again replaces it.

```bash
curl -s -X POST https://velven.ai/api/spaces/<slug>/secret -H "authorization: Bearer $VELVEN_TOKEN"
# { "secret": "…", "message": "Shown once. Put it in the host's server environment …" }
```

In the page, get the visitor's token with `signIn` and send it to your server with the run:

```js
const auth = await Velven.signIn({ silent: true });
if (auth.ok) {
  await fetch("/api/score", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token: auth.token, value: score, request_id: crypto.randomUUID() }),
  });
}
```

On your server, verify the token against `https://velven.ai/.well-known/jwks.json`: the issuer is `https://velven.ai`, the audience is your space's origin, the algorithm ES256, and the `space` claim is the space's id. Then POST `https://velven.ai/api/v1/scores` with the secret in `x-velven-secret`. A Netlify function, with the `jose` package:

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const VELVEN = "https://velven.ai";
const SPACE_ORIGIN = "https://your-space.netlify.app";
const jwks = createRemoteJWKSet(new URL(`${VELVEN}/.well-known/jwks.json`));

export default async (request) => {
  const { token, value, request_id } = await request.json();
  try {
    await jwtVerify(token, jwks, { issuer: VELVEN, audience: SPACE_ORIGIN, algorithms: ["ES256"] });
  } catch {
    return Response.json({ error: "invalid_token" }, { status: 401 });
  }
  const res = await fetch(`${VELVEN}/api/v1/scores`, {
    method: "POST",
    headers: { "content-type": "application/json", "x-velven-secret": process.env.VELVEN_BOARD_SECRET },
    body: JSON.stringify({ token, board: "main", value, request_id }),
  });
  return Response.json(await res.json(), { status: res.status });
};
```

`request_id` makes the post idempotent: a retry with the same id is answered as the first one was and entered once. Velven checks the token again itself, so a leaked secret alone cannot name a player. The answers:

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | `{ "ok": true, "board", "value", "total", "improved", "rank" }` | Entered. `total` is what the board ranks the player by now. |
| 401 | `{ "error": "invalid_secret" }` | No space has that secret. Issue it again and put the new one in the environment. |
| 401 | `{ "error": "expired_token" }` or `invalid_token` | The player's token is past its hour, or not one Velven signed. Ask the page for a fresh one. |
| 403 | `{ "error": "wrong_space" }` | The token was minted for another space. |
| 403 | `{ "error": "banned" }`, `server_only`, `client_only` | The player is banned, or the board's trust does not take this path. |
| 404 | `{ "error": "unavailable" }` or `no_board` | The space is not published and claimed, or there is no board with that key. |
| 422 | `{ "error": "out_of_range", "min", "max" }` or `invalid_value` | Outside the board's range, or not a finite number, or `meta` over 1 KB. |
| 429 | `{ "error": "cooldown", "retry_after" }` or `rate_limited` | Too soon for this player, or more than 600 posts from this space in a minute. |
| 400 | `{ "error": "bad_request" }` | The body was not `{ token, board, value, request_id }` with an optional `meta`. |

## 6. Test locally

On `localhost` the SDK runs in the `local` environment before any probe, with fake answers chosen by the page's query string, so the whole feature is built and drawn on your machine before the space is listed.

- `?velven_user=alice` makes `signIn` answer the user `{ id: "local-alice", handle: "alice", avatar: null }` with a fake token good for an hour. Without it, `signIn` answers `signed_out`, as for a guest.
- `?velven_token=expired` makes the token already past its hour, so the re-request path runs and a server of yours refuses it.
- Scores go to an in-memory board per key, ranked by the page's own block: `sort`, `mode`, `min`, `max` and `cooldown` apply, and three fake players are seeded so the board has rows to draw. Nothing survives a reload.
- No block on the page, or one that does not parse, answers `no_board` and warns once in the console with the reason. The same warning appears in every environment when a score call finds no valid block.

A top-level window on any other host is the `site` environment: the space plays, and every identity and score call answers `unavailable`. Identity and boards work inside Velven's page only, so a space with accounts of its own uses them on its own site and checks `environment` before drawing a board.

## 7. Worked examples

Two complete pages, one per tier, for a game called Orbit Dodger. The first is a single page that loads the script tag and posts on the client tier; it draws its board on the game-over screen from `top` and `around`. The second bundles the package and posts on the server tier through its own function, the one in section 5. Both declare one board, `main`, in the page's block. In a React component of your own named Velven, import the package under another name: `import { Velven as sdk } from "velven"`.

```html
<!-- Orbit Dodger, the client tier: the page posts the score itself, and the board says so. -->
<!doctype html>
<html>
<head>
  <meta name="velven" content="@mara">
  <script type="application/velven+json">
  {"boards":[{"key":"main","trust":"client","metric":"points","sort":"desc","min":0,"max":100000,"cooldown":5}]}
  </script>
  <script src="https://velven.ai/sdk/v1.js"></script>
</head>
<body>
  <canvas id="game"></canvas>
  <ol id="board"></ol>
  <button id="sign-in" hidden>Sign in to be ranked</button>
  <script>
    const board = document.getElementById("board");
    const signIn = document.getElementById("sign-in");

    async function drawBoard() {
      const top = await Velven.scores.top({ limit: 10 });
      if (!top.ok) return;
      board.replaceChildren(); // rows are other players' data: text only, never markup
      const line = (text) => { const li = document.createElement("li"); li.textContent = text; board.append(li); };
      for (const r of top.rows) line(`#${r.rank} @${r.user.handle} ${r.value}`);
      if (Velven.user) {
        const mine = await Velven.scores.around({ each: 0 });
        const me = mine.ok ? mine.rows.find((r) => r.user.id === Velven.user.id) : null;
        if (me) line(`You: #${me.rank} with ${me.value}`);
      }
    }

    // The game fires this when a run ends; the score is the run's points.
    addEventListener("game:over", async (event) => {
      const result = await Velven.scores.submit(event.detail.points);
      if (!result.ok && result.error === "signed_out") signIn.hidden = false; // a guest: offer sign-in, never force it
      await drawBoard();
    });

    // Sign-in from a button, never on load: the card comes up for a guest, silently for a signed-in visitor.
    signIn.addEventListener("click", async () => {
      const auth = await Velven.signIn();
      if (auth.ok) signIn.hidden = true;
    });

    Velven.ready().then((environment) => {
      if (environment !== "site") drawBoard(); // "velven" in the frame, "local" on your laptop
    });
  </script>
</body>
</html>
```

```js
// Orbit Dodger, the server tier, bundled: the page hands its function the player's token; the function holds the secret.
import { Velven } from "@velven/sdk";

const post = (path, body) =>
  fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });

export async function onRunOver(points) {
  const auth = await Velven.signIn(); // from the run that just ended, which the player pressed a key to finish
  if (auth.ok) {
    // Your own server (section 5) verifies the token, then posts to Velven with the secret.
    await post("/api/score", { token: auth.token, value: points, request_id: crypto.randomUUID() });
  }
  const top = await Velven.scores.top({ limit: 10 });
  if (top.ok) drawBoard(top.rows, top.trust); // trust is "server" here; say so on the board
}

Velven.onAuth((user) => showName(user.handle)); // a later sign-in, through the card or another button
```

## Reference

Environments, from `Velven.ready()` and `Velven.environment`:

| Value | When | What works |
| --- | --- | --- |
| `velven` | Inside the Velven frame, once the player has answered the SDK's probe and the first silent ask. | Everything: identity from the start, scores, reads. |
| `site` | A top-level window on a real host, or a frame nobody answered within seven seconds. | The space plays; identity and score calls answer `unavailable`. Velven's identity and boards are the Velven page's. |
| `local` | `localhost`, `127.0.0.1` or `[::1]`, or `?velven_local=1`. | Fake identity from the query string, an in-memory board per key. |

Results. Every call resolves; `ok` tells the two shapes apart.

| Call | `ok: true` | `ok: false` |
| --- | --- | --- |
| `signIn(options?)` | `{ user, token, expiresAt }` | `{ error }`: `signed_out`, `unavailable`, `rate_limited`, `failed` |
| `scores.submit(value, options?)` | `{ board, rank, value, total, improved }` | `{ error, retryAfter? }`: the codes in section 4 |
| `scores.top(options?)` | `{ board, trust, rows }` | `{ error }`: the codes in section 4 |
| `scores.around(options?)` | `{ board, trust, rows }` | `{ error }`: the codes in section 4 |

Limits:

- 10 boards per space; a board key is 1 to 32 lowercase letters, digits, `-` or `_`.
- `meta` up to 1 KB of JSON per submission.
- The identity token lives one hour; the SDK re-requests it when under a minute is left.
- From the frame: 40 score calls a minute per space page (`submit`, `top` and `around` together, answered `rate_limited` by the player without a request past that), 12 sign-in asks a minute per space page (`rate_limited`), and across every space a visitor has open, 60 submissions and 60 token requests a minute per account and 120 reads a minute per address
- `signIn` waits up to eleven minutes for the card, then answers `failed`; a score call waits thirty seconds.

Moderation, on the creator's bearer token or the edit page:

```bash
# the boards as they stand
curl -s https://velven.ai/api/spaces/<slug>/boards -H "authorization: Bearer $VELVEN_TOKEN"
# delete one submission; the player's summary is remade from what is left
curl -s -X DELETE https://velven.ai/api/spaces/<slug>/scores/<id> -H "authorization: Bearer $VELVEN_TOKEN"
# ban a player from every board of the space, and lift it
curl -s -X POST https://velven.ai/api/spaces/<slug>/bans -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" -d '{"handle":"@mara"}'
curl -s -X DELETE https://velven.ai/api/spaces/<slug>/bans -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" -d '{"handle":"@mara"}'
# the secret's status: since when, never the secret itself
curl -s https://velven.ai/api/spaces/<slug>/secret -H "authorization: Bearer $VELVEN_TOKEN"
```

This page as markdown: https://velven.ai/docs/sdk.md. The agent quickstart for listing a space: https://velven.ai/docs/agent.md. Index: https://velven.ai/llms.txt.
