# Publish with an agent

A coding agent or a chat assistant can put a space on Velven for its creator. A coding agent publishes the build with the Velven CLI; an assistant in a chat publishes through Velven's MCP server; and a space already live on another host is listed through the API. The creator approves each sign-in in their browser, so no key is ever copied by hand.

## Choose a way in

| You are | Use |
| --- | --- |
| A coding agent with a terminal, and the space is a folder or a build | The [Velven CLI](https://velven.ai/docs/agent#cli): `npx @velven/cli publish`. |
| An assistant in a chat, such as Claude or ChatGPT | [Velven's MCP server](https://velven.ai/docs/agent#mcp), with one page or a few files. |
| Any agent, and the space is already live on one of the 7 supported hosts | The [listing API](https://velven.ai/docs/agent#login), in the steps below. |

Ask the creator for the space's title, its type and the devices it works on, or take them from what they already said. Never guess them: they are the listing people see.

## Publish with the CLI

Run the CLI with `--json`, so every command prints one JSON object, and `--yes`, so it never waits on a prompt. The [CLI reference](https://velven.ai/docs/cli) has every flag and exit code.

```bash
npx @velven/cli login                                   # prints a code and a link: show both to the creator
npx @velven/cli publish ./dist --title "Orbit Dodger" --type game --devices desktop,mobile --yes --json
npx @velven/cli publish ./dist --prod --yes --wait --json   # when the creator asks to go live
```

- `login` prints the code and the approval link and waits until the creator approves. Show them both; the token is saved for this creator's later publishes.
- The first publish writes `velven.json` into the folder, with `"space"`; keep it, so later publishes update the same space.
- A plain publish answers `status: "preview"` and a `previewUrl` to show the creator. With `--prod --wait` it answers the verdict: `live` with `pageUrl`, or `refused` or `unjudged` with `reason` and `shownOnTake`, exit 6.
- For `unjudged`, add a `start` hint to `velven.json`, such as `"start": { "click": "Play" }`, and publish again. For `refused`, tell the creator the reason; they can ask for a review from `watchUrl`.

Without `login`, the same publish makes an [unlisted page](https://velven.ai/docs/hosting#no-account) and answers `pageUrl`, `claimToken`, `claimUrl` and `expiresAt`, and `terms`. Show the creator all of them, say the page is deleted after 7 days unless they claim it, and that publishing means they agree to Velven's Terms. Running `login` and publishing again from the folder claims it.

## Publish from a chat with the MCP server

Velven's MCP server is at `https://mcp.velven.ai/mcp` (Streamable HTTP). Add it to an assistant as a custom connector, or to Claude Code with its [plugin](https://velven.ai/docs/skills#plugin) or one command:

```bash
claude mcp add --transport http velven https://mcp.velven.ai/mcp
```

| Tool | What it does | Sign-in |
| --- | --- | --- |
| `publish` | Publishes one HTML page (`html`) or a few files (`files`, up to 3 MB in all) as a new space or a new version (`space`). A private preview by default; `prod: true` goes live after the safety check. A `velven.json`, a dotfile, a dot-folder or `node_modules` among the files, in any folder, is left out and named, never published: `velven.json`'s settings go in the tool's fields, or publish the folder with the CLI. | Optional |
| `my_spaces` | The account's spaces: slug, title, status, whether Velven hosts it, and its page. | Needed |
| `versions` | A hosted space's versions, newest first, with their status. | Needed |
| `rollback` | Puts a version that was live before live again. | Needed |
| `search_docs` | Searches these docs and answers the best sections, with links. | None |

A tool that needs an account makes the app ask the creator to sign in to Velven and approve the connection in their browser. Without signing in, `publish` makes an unlisted page and answers its address, a claim token, a claim link and a Terms line: show every one of them, and tell the creator to keep the token, the only way to update the page or keep it past 7 days. Signed in later, `publish` with `claim` claims it.

> Note: For a folder over 3 MB, or from a terminal, use the [CLI](https://velven.ai/docs/agent#cli).

## 1. Ask for a sign-in link

The rest of this page lists a space already live on a supported host, and reads and changes a listed space. The sign-in is the same device-code flow the CLI runs. Make one unauthenticated call. `agent_name` is optional, up to 60 characters, and is shown to the creator on the approval screen.

```bash
curl -s -X POST https://velven.ai/api/agent/login \
  -H "content-type: application/json" \
  -d '{"agent_name":"Claude Code"}'
```

The response, with status 201:

```json
{
  "device_code": "a3Vw…",
  "user_code": "K7PX-4MDQ",
  "verification_url": "https://velven.ai/agent/approve?code=K7PX-4MDQ",
  "expires_in": 900,
  "interval": 5
}
```

Keep `device_code` to yourself. Show the creator `verification_url` and `user_code`.

> Note: A 429 `rate_limited` means this address asked for too many sign-in links in the last 15 minutes. Wait; do not retry in a loop.

## 2. Wait for the creator to approve

Tell the creator: "Open this link and press Approve. The code on the page should read K7PX-4MDQ." If they are not signed in to Velven, the same screen signs them in with GitHub, Google or an email link, and asks for a handle.

Meanwhile, poll for the token every `interval` seconds, and give up after `expires_in` seconds:

```bash
curl -s -X POST https://velven.ai/api/agent/token \
  -H "content-type: application/json" \
  -d '{"device_code":"<device_code>"}'
```

| Status | Body | What to do |
| --- | --- | --- |
| 200 | `{ "access_token", "token_type": "bearer", "handle", "profile_url" }` | Approved. Store the token: it is returned once and lasts 90 days. |
| 428 | `{ "error": "authorization_pending", "interval": 5 }` | Not decided yet. Wait `interval` seconds and poll again. |
| 403 | `{ "error": "access_denied" }` | The creator pressed Deny. Stop and say so. |
| 400 | `{ "error": "expired_token" }` | 15 minutes passed, or the code is unknown. Start over from step 1. |
| 400 | `{ "error": "invalid_grant" }` | This device code was already exchanged. Use the token you have, or start over. |
| 400 | `{ "error": "invalid_request" }` | The body was not `{ "device_code": "…" }`. Fix the request. |
| 5xx | `{ "error": "server_error" }`, or no answer | A problem on Velven's side. Keep polling until `expires_in` runs out. |

Store the token at `~/.config/velven/token` with mode 600 and reuse it for this creator's later spaces. A token belongs to one creator and works only on the endpoints on this page and in the [REST API](https://velven.ai/docs/api). When a call answers 401, the token expired or was revoked: delete the file and sign in again.

To check who a token belongs to:

```bash
curl -s https://velven.ai/api/agent/me -H "authorization: Bearer $VELVEN_TOKEN"
```

```json
{ "handle": "mara", "profile_url": "https://velven.ai/mara" }
```

## 3. Put the proof on the site

Only a verified creator can list a space, so the proof goes on the site before you submit. Add this tag to the page's `<head>`, with the `handle` from the token response, then deploy again:

```html
<meta name="velven" content="@handle">
```

Wait until the deploy is live before submitting; a submit that comes too early answers 409 with the change still needed. Velven lists spaces on Vercel, Netlify, GitHub Pages, Cloudflare, Replit, Firebase and ChatGPT sites. [Linking a live URL](https://velven.ai/docs/hosting#hosts) has the detail for each host, including the framing header a page must allow.

> Note: A ChatGPT site must be published with "Who has access" set to "Anyone on the Internet". A site only its owner can open answers 401 and cannot be verified.

## 4. Submit the space

Fill in the details from the project rather than asking the creator: the engine from `package.json` and the imports, the AI tools from yourself, the devices from the pointer and touch handling, the description from the README. Velven fetches the URL, checks the proof, and fills anything you leave out from the page: the title, the meta description, the engine its scripts name and the tool its markup names. What you send always wins.

```bash
curl -s -X POST https://velven.ai/api/spaces \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d @- <<'JSON'
{
  "url": "https://orbit-dodger.netlify.app",
  "title": "Orbit Dodger",
  "description": "Dodge debris in a decaying orbit. Arrow keys or swipe.",
  "space_type": "game",
  "engine": "three.js",
  "models": ["claude-opus-5"],
  "ai_tools": ["claude-code"],
  "devices": ["desktop", "mobile"],
  "how_made": "One session with Claude Code. Three.js scene, hand-rolled physics, procedural debris field.",
  "source_url": "https://github.com/mara/orbit-dodger"
}
JSON
```

| Field | Required | Notes |
| --- | --- | --- |
| `url` | Yes | The space's public http(s) URL. Stored after redirects. |
| `space_type` | Yes | One of the [allowed values](https://velven.ai/docs/agent#values). |
| `devices` | Yes | An array of at least one allowed value. |
| `title` | No | Up to 80 characters. Defaults to the page's `og:title` or `<title>`. |
| `description` | No | One line, up to 160 characters. Defaults to the meta description. |
| `engine` | No | An allowed value. Left out, Velven reads it from the page's scripts; send `null` for none. |
| `models` | No | An array of the [models](https://velven.ai/docs/agent#values-models) that wrote it. |
| `ai_tools` | No | An array of the tools the model ran in. Left out, Velven reads it from the page's markup; send `[]` for none. |
| `how_made` | No | Up to 2000 characters: the prompt, or how it was made. Shown as Prompt on the page. |
| `source_url` | No | The repository's URL. |
| `about`, `how_to_play`, `controls`, `features`, `faq`, `orientation`, `players` | No | The text of the space's page. See [Your space's page](https://velven.ai/docs/agent#page). |

The response, with status 201. The page exists at once on the creator's handle but only the creator can see it while `status` is `processing`. It goes public, as `published`, once Velven has recorded its clip and thumbnail, a few minutes later.

```json
{
  "slug": "orbit-dodger",
  "url": "https://velven.ai/mara/orbit-dodger",
  "status": "processing",
  "title": "Orbit Dodger"
}
```

When the page carries a boards block, the answer also has `boards`, the keys Velven synced from it, or `boards_error`, why the block was not taken.

| Status | Body | Meaning |
| --- | --- | --- |
| 401 | `{ "error": "unauthorized" }` | The token is missing, revoked or unknown. Sign in again. |
| 403 | `{ "error": "blocked" }` | This URL or account cannot list spaces. |
| 409 | `{ "error": "unverified", "instruction", "snippet" }` | The proof is not on the live site yet. Make the change `instruction` names, deploy, wait for the host's cache, and call again. Do not retry more than a few times without changing something. |
| 409 | `{ "error": "duplicate", "url" }` | Already on Velven; `url` is its page. If it shows as unclaimed, [claim it](https://velven.ai/docs/agent#claim). |
| 422 | `{ "error": "invalid", "issues": [{ "path", "message" }] }` | Fix the fields named and retry. An issue on `url` can also mean Velven does not list that host. |
| 422 | `{ "error": "unreachable", "message", "instruction" }` | The URL did not answer (a timeout, DNS, a refused connection), or is shut to visitors (a Vercel deployment behind Vercel Authentication, or a site shared with its owner only). Check the deployment is public and call again. `instruction`, saying how to open it, comes only with a shut page. |
| 422 | `{ "error": "unframeable", "instruction", "snippet" }` | The site's headers refuse to let Velven frame it. Make the change `instruction` names, deploy, and call again. |
| 500 | `{ "error": "server_error", "message" }` | A problem on Velven's side. Retry once after a moment; if it persists, tell the creator. |

## 5. Reply to the creator

Reply with the `url`, and offer the badge for the README:

```html
<a href="https://velven.ai/mara/orbit-dodger"><img src="https://velven.ai/badge/orbit-dodger" alt="On Velven"></a>
```

To list what this creator has already listed:

```bash
curl -s "https://velven.ai/api/spaces?mine=1" -H "authorization: Bearer $VELVEN_TOKEN"
```

```json
[{ "slug": "orbit-dodger", "title": "Orbit Dodger", "url": "https://orbit-dodger.netlify.app", "plays": 412, "upvotes": 18, "velven_url": "https://velven.ai/mara/orbit-dodger" }]
```

## Keep the clip current

After listing, Velven plays the space in its own browser and records a 5-second clip; its first frame becomes the thumbnail. When the site is rebuilt, Velven's background check notices the change within 6 hours and records again. A space published on Velven gets its clip from the safety check of each version that goes live.

After a deploy that changes how the space looks, ask for a retake at once. `note` is optional: up to 100 characters on what the clip should show, such as a mode, a moment or something to avoid. The creator gets 2 retakes per space; the background check's own do not count.

```bash
curl -s -X POST https://velven.ai/api/spaces/recapture \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "slug": "orbit-dodger", "note": "start after the title screen, night mode" }'
```

| Status | Body | Meaning |
| --- | --- | --- |
| 202 | `{ "slug", "status" }` | Queued, or already queued or running; `status` says which. The clip lands within a few minutes. |
| 401 | `{ "error": "unauthorized" }` | The token is missing, revoked or unknown. Sign in again. |
| 404 | `{ "error": "not_found" }` | No published or processing space with that slug on this account. |
| 409 | `{ "error": "no_retakes_left" }` | Both retakes are used. The background check still records again when the page changes. |
| 422 | `{ "error": "invalid", "issues" }` | The body was not `{ slug, note? }`, or `note` is over 100 characters. |
| 503 | `{ "error": "capture_off" }` | Recording is paused on Velven's side. Nothing to fix; try later. |

## Your space's page

Under the frame, the space's page says what the space is, how to play it and which keys do what. Velven writes this text from its own recording, but you know the project better: write it from the code and send it with the listing, or change it later. Every field you send is the creator's, and Velven's recording never writes over it, not even a field you cleared.

Take the controls from the input handlers (the key bindings, the pointer and touch events), the goal and the rules from the game logic, and the features from what the project actually does. Write plainly, in the second person, for someone about to play. For a world, a tool or a wonder, leave out `how_to_play` and `controls` if they do not apply.

| Field | Limit | Notes |
| --- | --- | --- |
| `about` | Up to 1500 characters | What the space is, a paragraph or two. |
| `how_to_play` | Up to 1000 characters | The goal and how to reach it. |
| `controls` | Up to 4 groups, 12 rows in all | Groups of `{ "label", "rows": [{ "input", "action" }] }`. `label` is optional, up to 40 characters; `input` up to 40, such as `W`, `Space` or `Mouse drag`; `action` up to 80. |
| `features` | Up to 6 items, 100 characters each | Short lines, what sets the space apart. |
| `faq` | Up to 8 pairs | `{ "q", "a" }`, a question up to 150 characters and an answer up to 500. Only the creator writes this; Velven never does. |
| `orientation` | One value | `landscape`, `portrait`, `any`: how the space is meant to be held on a phone. |
| `players` | One value | `single` single player, `multi` multiplayer. |

In the body of `POST /api/spaces`, beside the other fields:

```json
{
  "about": "Orbit Dodger drops you into a decaying orbit full of debris. Every lap is faster than the last.",
  "how_to_play": "Stay alive as long as you can. Collect fuel cells to climb back to a higher orbit.",
  "controls": [
    { "label": "Keyboard", "rows": [{ "input": "Left / Right", "action": "Thrust" }, { "input": "Space", "action": "Boost" }] },
    { "label": "Touch", "rows": [{ "input": "Swipe", "action": "Thrust" }] }
  ],
  "features": ["Procedural debris field", "A new orbit every run"],
  "orientation": "landscape",
  "players": "single"
}
```

If the listing is made but its page text could not be saved, the 201 response carries `"page_not_written": true`. The space stands without the text; send the same fields again with `PATCH /api/spaces/{slug}`.

To read the page as it stands, with who wrote each field in `page_authors` (`model` for Velven, `creator` for you), call `GET /api/spaces/{slug}`. To change it, send any of these fields, or any of the listing's own (`title`, `description`, `space_type`, `engine`, `models`, `ai_tools`, `devices`, `how_made`, `source_url`), to `PATCH /api/spaces/{slug}`. Only the fields you name change, and `null` clears one. Both answer the space's fields.

```bash
curl -s https://velven.ai/api/spaces/orbit-dodger -H "authorization: Bearer $VELVEN_TOKEN"

curl -s -X PATCH https://velven.ai/api/spaces/orbit-dodger \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "features": ["Procedural debris field", "Daily seed"], "faq": null }'
```

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | The space's fields, `page_authors` and `velven_url` | Read, or changed. |
| 401 | `{ "error": "unauthorized" }` | The token is missing, revoked or unknown. Sign in again. |
| 404 | `{ "error": "not_found" }` | No space with that slug on this account. |
| 422 | `{ "error": "invalid", "issues": [{ "path", "message" }] }` | Fix the fields named. A field that cannot be changed is refused by name; to change `url`, [move the space](https://velven.ai/docs/agent#move) with `POST /api/spaces/{slug}/move`. |
| 500 | `{ "error": "server_error", "message" }` | Saving failed. The message says whether the page's text was saved (`page_written`) and the other fields were not; send what did not land again. |

## Move to another host

When the space moves to a new URL, move its listing with it. Its slug, plays, likes, boards, scores, saves and page text stay. Velven checks the new page as it checks a listing, so first put the [proof tag](https://velven.ai/docs/hosting#proof) on the new page and [allow framing](https://velven.ai/docs/hosting#framing) there, deploy, then call:

```bash
curl -s -X POST https://velven.ai/api/spaces/orbit-dodger/move \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "url": "https://orbit-dodger.vercel.app" }'
```

It answers the space's fields as `GET /api/spaces/{slug}` does, with `previous_url`. Every refusal and its code is in the [REST API](https://velven.ai/docs/api#move).

- If a server posts scores, set the board secret in the new host's server environment: copy the existing one if you have it, or issue a new one with `POST /api/spaces/{slug}/secret`, which replaces the old one at once, so the old host stops posting.
- Identity tokens name the new origin as their audience (`aud`) from the move on. A server that checks `aud` must expect the new origin; tokens issued before the move carry the old one for up to an hour.
- The old origin keeps its `localStorage`, so a game that saves there starts empty on the new one. `Velven.data` saves belong to the space and move with it.

## Leaderboards and sign-in

If the space keeps a score, give it a hosted leaderboard and sign-in with the Velven SDK. The [SDK guide](https://velven.ai/docs/sdk) covers all of it, and [https://velven.ai/docs/sdk.md](https://velven.ai/docs/sdk.md) is the same guide as one file for you to read. In short: load the script or install `@velven/sdk`, declare the boards in a `<script type="application/velven+json">` block beside the [proof tag](https://velven.ai/docs/hosting#proof) (or in `velven.json` for a space published on Velven), and post scores with `Velven.scores.submit`. The space draws its own boards from `top` and `around`; a board declared with `display` `page` or `both` also shows on its Velven page. [Achievements](https://velven.ai/docs/sdk/achievements) are declared the same way.

```html
<meta name="velven" content="@handle">
<script type="application/velven+json">{"boards":[{"key":"main","trust":"client","metric":"points","sort":"desc"}]}</script>
<script src="https://velven.ai/sdk/v1.js"></script>
```

Choose a tier for each board. `trust: "server"`, the default, takes posts only from the space's own server, with the space's secret and the player's token; a page's own post to it answers `server_only`. `trust: "client"` takes the page's posts within a range, a cooldown and caps. A space with no backend uses `client`, as in the snippet above; [Server scores](https://velven.ai/docs/sdk/server) helps choose.

A server board needs the secret, issued on the creator's token and shown once. Put it in the host's server environment, never in the page:

```bash
curl -s -X POST https://velven.ai/api/spaces/<slug>/secret -H "authorization: Bearer $VELVEN_TOKEN"
```

Boards can also be declared or changed without a deploy, in the same shape as the block. Only the boards named are written, and the page's next sync overwrites a key it also names:

```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":"client","metric":"points","sort":"desc","min":0,"max":100000,"cooldown":5}]}'
```

Reading the boards is in the [REST API](https://velven.ai/docs/api#boards), and deleting a submission or banning a player under [Moderation](https://velven.ai/docs/api#moderation).

## Claim a space Velven listed

Velven lists some spaces itself. They show as unclaimed at `/s/slug`, and a `duplicate` answer on submit can point at one. With the proof on the site, claim it for the creator: its plays stay, and it moves to their handle.

An [unlisted page](https://velven.ai/docs/hosting#no-account) published without an account is claimed with its token instead: run `velven login` and publish again from the folder that holds its `velven.json`, or have the creator open the claim link.

```bash
curl -s -X POST https://velven.ai/api/spaces/verify \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "slug": "orbit-dodger" }'
```

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | `{ "verified": true, "url" }` | Done. `url` is now the creator's page, `/handle/slug`. |
| 403 | `{ "error": "blocked" }` | This account cannot claim spaces. Tell the creator and stop. |
| 404 | `{ "error": "not_found" }` | No published space has that slug. |
| 409 | `{ "error": "unverified", "instruction", "snippet" }` | The proof is not on the live site yet. Deploy, wait, and call again. |
| 409 | `{ "error": "owned" }` | Someone else already verified it. Tell the creator; they can tell Velven with Feedback on the space's page. |
| 422 | `{ "error": "invalid", "issues" }` | The body was not `{ slug }`. |

## Allowed values

### `space_type`

- `game` Game
- `world` World
- `tool` Tool
- `wonder` Wonder

### `engine`

- `three.js` Three.js
- `r3f` React Three Fiber
- `babylon.js` Babylon.js
- `playcanvas` PlayCanvas
- `a-frame` A-Frame
- `godot` Godot web
- `unity` Unity web
- `phaser` Phaser
- `p5.js` p5.js
- `canvas` Canvas / vanilla JS
- `webgl` WebGL / shaders
- `webgpu` WebGPU / shaders
- `wasm` WebAssembly
- `dom` HTML / DOM
- `marble` Marble
- `spline` Spline
- `other` Other

### `models`

- `claude-fable-5.1` Claude Fable 5.1
- `claude-opus-5.5` Claude Opus 5.5
- `claude-opus-5` Claude Opus 5
- `claude-sonnet-5` Claude Sonnet 5
- `gpt-6-astra` GPT-6 Astra
- `gpt-6-sol` GPT-6 Sol
- `gpt-6-luna` GPT-6 Luna
- `gpt-5.6-sol` GPT-5.6 Sol
- `gemini-3.8-flash` Gemini 3.8 Flash
- `muse-spark-1.3` Muse Spark 1.3
- `grok-4.7` Grok 4.7
- `grok-4.6` Grok 4.6
- `kimi-k3` Kimi K3
- `glm-5.3` GLM-5.3
- `jev` Jev
- `other` Other

### `ai_tools`

- `claude-code` Claude Code
- `claude` Claude
- `cursor` Cursor
- `codex` Codex
- `copilot` GitHub Copilot
- `gemini` Gemini
- `windsurf` Windsurf
- `lovable` Lovable
- `bolt` Bolt
- `v0` v0
- `replit` Replit
- `marble` Marble (world model)
- `muse-code` Muse Code
- `grok-build` Grok Build
- `kimi-code` Kimi Code CLI
- `opencode` Opencode
- `devin` Devin
- `other` Other

### `devices`

- `desktop` Desktop
- `mobile` Mobile
- `vr` VR headset

## In the browser

If you drive a browser instead, Velven's pages expose their actions as WebMCP tools on `document.modelContext`. Call those rather than reading the page: search_spaces on every page; inspect_space_url and publish_space on /add; check_claim and claim_with_token on a claim page; enter_space, upvote_space, save_space and report_space on a space page; email_sign_in_link and choose_handle on sign-in.

On the add page, `inspect_space_url` works signed out. `publish_space` needs the creator signed in: it lists the space at once when the proof is already on the site, and otherwise keeps the details, shows the proof steps and re-checks the site until it clears. Chrome ships WebMCP in an origin trial; other browsers see the same pages without the tools.

> Note: Agents that use skills can install Velven's `onboard` skill, which carries this whole flow: [Agent skills](https://velven.ai/docs/skills).
