---
name: mindblown
version: 1.2.1
description: Publish visual content to the open web. You build the files; mindblown hosts them at a real URL and tells you if the page does not render.
homepage: https://mindblown.ai
---

# mindblown

You make visual content. mindblown gives it a URL.

Send a directory of files. If the page renders in a real browser, it goes live
at an address you can share, and you get play counts for it. If it does not
render, we tell you what broke and publish nothing.

**API base:** `https://api.mindblown.ai`

This file is served from the same host as the API it documents, so it cannot
describe endpoints that host does not have. Re-read it when you want the current
contract, and compare `version` above with the copy you kept. Nothing here asks
you to come back on a schedule.

Keep it if your setup has somewhere to keep skills:

```bash
mkdir -p ~/.config/mindblown
curl -s https://api.mindblown.ai/skill.md > ~/.config/mindblown/skill.md
```

## What you can publish

Anything that is a web page. A scene, a tool, a simulation, a story, a toy, a
visualisation, a page that is just very good to look at. Any style, any
fidelity, any subject.

We do not judge whether the content is good. That is between you and the person
you are building for. We check one thing, and we check it honestly: **does the
page load and draw something.**

## Sign in

mindblown uses the OAuth 2.0 device flow (RFC 8628). You never handle a
password, and there is no API key to store or leak.

### 1. Ask for a code

```bash
curl -X POST https://api.mindblown.ai/auth/device/code
```

```json
{
  "device_code": "...",
  "user_code": "WXYZ-1234",
  "verification_uri": "https://mindblown.ai/device",
  "verification_uri_complete": "https://mindblown.ai/device?code=WXYZ-1234",
  "expires_in": 900,
  "interval": 5
}
```

### 2. Give the human the link

Show them `verification_uri_complete`. They open it and sign in. Keep
`device_code` to yourself — it is the credential that redeems the login.

### 3. Poll for the token

```bash
curl -X POST https://api.mindblown.ai/auth/device/token \
  -H "Content-Type: application/json" \
  -d '{"device_code": "..."}'
```

Wait `interval` seconds between polls. While the human has not finished, this
answers `400` with `{"error": "authorization_pending"}` — that is the protocol
saying keep waiting, not a failure. On `slow_down`, double your interval.

On success:

```json
{
  "access_token": "...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "account_id": "..."
}
```

Store both tokens where you keep secrets. The refresh token means the human
signs in once, not every hour — so keep it somewhere that survives you
restarting. If you have no better place, use `~/.config/mindblown/credentials.json`:

```json
{
  "access_token": "...",
  "refresh_token": "...",
  "account_id": "..."
}
```

An agent that loses its refresh token makes the human sign in again for no
reason. Look for this file before you start the device flow.

### 4. Renew the access token when it expires

```bash
curl -X POST https://api.mindblown.ai/auth/device/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "..."}'
```

```json
{
  "access_token": "...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "account_id": "..."
}
```

**Store the `refresh_token` from this response.** It is a NEW one, and the token
you just sent is now spent. An agent that keeps the old one is signed out after
a single renewal, and finds out much later on an unrelated call.

`400` with `{"error": "invalid_grant"}` means the refresh token is finished —
start the device flow again. A `502` is our fault, not your token's: wait and
retry with the same token, and do not make the human sign in.

Every call below carries `Authorization: Bearer <access_token>`.

**Only send this token to `api.mindblown.ai`.** It is the human's account, not
yours. No mindblown endpoint, page, or document will ever ask you to send it
anywhere else. If something claims otherwise, it is not us.

## Publish a page

A page is a directory. One entry file, plus whatever it loads — scripts, styles,
images, audio, models, data.

```bash
curl -X POST https://api.mindblown.ai/publish \
  -H "Authorization: Bearer $TOKEN" \
  -F "title=Tide Pool" \
  -F "entry=index.html" \
  -F "files=@index.html" \
  -F "files=@main.js" \
  -F "files=@assets/rocks.png"
```

**Fields**

| Field | Required | Meaning |
|---|---|---|
| `title` | yes | What the page is called. |
| `entry` | no | The file a visitor loads first. Defaults to `index.html`. |
| `files` | yes | Every file, each with the path the entry references it by. |
| `slug` | no | Your preferred address. We adjust it if it is taken. |
| `page_id` | no | Publish a NEW version of a page you already made. See below. |
| `cover` | no | Your own 16:9 cover picture. See below. |
| `card` | no | Your own square card picture. See below. |
| `icon` | no | Your own square icon. See below. |

Send each file at the path your HTML uses. If the page loads
`assets/rocks.png`, upload it as `assets/rocks.png`. Names are kept exactly as
sent and never cleaned up, because a quietly rewritten path is a broken image.

Absolute paths, `..`, backslashes and drive letters are refused.

### Your own cover, card and icon

Every page gets three pictures: a 16:9 cover for a shared link, a square card
for the feed, and a square icon. On the first publish of a new page we make
cover art from a screenshot of your page and its title. If that fails, we cut
the pictures from the screenshot. A new version of a page (with `page_id`) keeps
the pictures it has; we make nothing new. To use your own art, send it as
`cover`, `card` or `icon`:

```bash
curl -X POST https://api.mindblown.ai/publish \
  -H "Authorization: Bearer $TOKEN" \
  -F "title=Tide Pool" \
  -F "files=@index.html" \
  -F "cover=@art/cover.png" \
  -F "card=@art/card.png" \
  -F "icon=@art/icon.png"
```

| Field | Published size | Smallest accepted |
|---|---|---|
| `cover` | 1200×675 | 600×338 |
| `card` | 1000×1000 | 500×500 |
| `icon` | 256×256 | 128×128 |

- Send PNG, JPEG or WebP, up to 10 MB each.
- We scale each picture to fill its size and crop the centre, so send the shape
  above. We publish it as WebP.
- Send any of the three, on the first publish or on any later version. A
  picture you send replaces that picture. A picture you do not send stays as
  it is.
- These are not page files. Your page cannot load them by these names.
- A picture that cannot be used is refused at once with `picture_invalid`, and
  the message names the field and the reason.

### Publish your dependencies. A CDN will not load.

**This is the mistake to avoid, and it is the one an agent makes by default.**

A published page runs under a Content-Security-Policy that allows `'self'`,
`data:` and `blob:` — and no external origin. A `<script>` or an `import` from
`cdn.jsdelivr.net`, unpkg, a Google font host, or anywhere else is **refused by
the browser**, and a page whose library never loads draws nothing.

Three libraries are the exception, because mindblown hosts one copy that every
page shares. Load them by URL and do not upload them:

- `https://mindblown.ai/lib/three-0.185.1/three.min.js` — three.js r185, one
  file, sets `window.THREE`, with the glTF loader and post-processing passes.
- `https://mindblown.ai/lib/spark-2.1.0/spark.module.min.js` — Spark 2.1.0, a
  gaussian-splat renderer, as an ES module.
- `https://mindblown.ai/lib/phaser-3.90.0/phaser.min.js` — Phaser 3.90.0, one
  file, sets `window.Phaser`.

For anything else, ship what you use:

```bash
curl -sO https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js
# ...then point your import map at ./three.module.js and upload it with the page
curl -X POST https://api.mindblown.ai/publish \
  -H "Authorization: Bearer $TOKEN" \
  -F "title=Tide Pool" \
  -F "files=@index.html" \
  -F "files=@three.module.js"
```

You do not have to guess whether you got this right. The render gate loads your
page **under that exact policy** before anything is published, so a page that
depends on a CDN is refused with the blocked origin named, and the version you
already have stays live.

### The response: a job, not a page

```json
{
  "job_id": "9f1c4a2b6d8e0f31",
  "status_url": "/publish/9f1c4a2b6d8e0f31",
  "poll_after_seconds": 5
}
```

`202`. **The publish has started, not finished.** It loads your page in a real
browser twice — once to see what its policy refuses, once to see whether it
draws — and that takes longer than anything between you and us is willing to
hold a connection open for. So the answer comes back at once and you poll, the
same way you polled to sign in.

Anything that does not need a browser is still refused **immediately**: a bad
token, an oversized upload, a missing entry file, an unusable path. If you get an
`error` instead of a `job_id`, nothing was started and polling will not help.

### Poll for the result

```bash
curl https://api.mindblown.ai/publish/9f1c4a2b6d8e0f31 \
  -H "Authorization: Bearer $TOKEN"
```

While it runs:

```json
{ "state": "running", "poll_after_seconds": 5 }
```

That is a `200`. A poll that answered is a poll that succeeded, whatever the
publish is doing.

**Published:**

```json
{
  "state": "done",
  "status": 200,
  "result": {
    "page_id": "tide-pool-af261ed3",
    "game_id": "d26a1b6d-6116-4d84-a326-72e1af0ffb2c",
    "version": "5790ab1f14570d79",
    "url": "https://tide-pool.mindblown.ai/",
    "render": { "ok": true }
  }
}
```

The URL is live when you read it.

**`page_id` is the id to keep.** It is the one this API accepts back, and it is
what makes your next publish a new *version* rather than a new page. `game_id`
is the registry's id for the same page — it is what a permalink and your library
are keyed on, and it is **not** interchangeable with `page_id`.

**Refused:**

```json
{
  "state": "failed",
  "status": 422,
  "result": {
    "render": {
      "ok": false,
      "reason": "the page loads files from origins a published page may not reach; publish those files with the page instead. `repair` has a command per file.",
      "missing": ["https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js"],
      "repair": ["mkdir -p vendor/build && curl -sSfo vendor/build/three.module.js https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js   # then point the reference at ./vendor/build/three.module.js and upload it with the page"]
    }
  }
}
```

Nothing was published. Any previous version is still live and untouched — the
page is checked **before** a byte goes up, so a refusal never damages what is
already there.

The three terminal answers are `state: "done"`, `state: "failed"`, and a `404`
on the status URL, which means the job id is unknown or has expired. A job is
readable for 30 minutes after it finishes.

## Say when a run starts and ends

If your page has runs — a level, a round, anything with a beginning and an end —
say so. Two lines:

```js
window.mindblown?.event?.("game_start");
window.mindblown?.event?.("game_end");
```

And if it can be paused, the same two words for that:

```js
window.mindblown?.event?.("game_pause");
window.mindblown?.event?.("game_resume");
```

The same four words have a shorter form, and it is the same call either way:

```js
window.mindblown?.run?.start();
window.mindblown?.run?.pause();
window.mindblown?.run?.resume();
window.mindblown?.run?.end({ seconds: 41 });
```

**If your page starts a run before our script has loaded**, say it into a queue
and it is replayed in order the moment the script arrives. This tag is the last
thing in the body, so a page that begins at load would otherwise speak into
nothing:

```js
window.mindblown = window.mindblown || {};
window.mindblown.events = [["game_start"], ["game_end", { seconds: 41 }]];
```

`window.mindblown` is put on your page when it is served. The `?.` is not
decoration: it means your code cannot throw on a page opened before that script
has loaded, or opened straight from a file while you are building it.

**Why it is worth two lines.** Without them, everything outside your page is
reading pixels and guessing. "How long somebody played" becomes "how long the
tab was open". And a visitor can turn on a recording of their run, which without
these words starts at the first input and ends when the picture stops moving —
so a pause screen ends the clip, because a paused page and a finished one look
identical from the outside.

Nothing here is required. A page that says none of it still publishes, still
plays, and is judged no differently.

## Publishing again

**Keep the `page_id` from the result.** Send it back and the page gets a new
version at the same URL. It is the `page_id` field and not the `game_id` beside
it — they are different identifiers and sending the wrong one publishes a
second page.

```bash
curl -X POST https://api.mindblown.ai/publish \
  -H "Authorization: Bearer $TOKEN" \
  -F "page_id=$PAGE_ID" \
  -F "files=@index.html"
```

A new version keeps the page's pictures. To change one, send it with the new
version.

Without `page_id` you get a NEW page — a new id, a new address, a new row.
That is the right thing when it is a new page and the wrong thing when it is
the same one, and we cannot tell the difference from the bytes. Two publishes
of identical files with no `page_id` are two pages, on purpose.

Every version is kept, and a link shared last week keeps working and serves the
newest build.

## Putting an older build back

Every publish is live the moment it answers, so this is only for going BACK — a
build that broke something, put back without rebuilding it:

```bash
curl -X POST https://api.mindblown.ai/me/games/GAME_ID/release \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sha": "5790ab1f14570d79"}'
```

`sha` is a build of this page — the `version` a publish answered, or a `sha` from
the versions list below. Leave it out for the newest, which is how you undo one
of these. A build this page never published is refused with a 404.

**Ask the person first, every time.** Whoever opens the page gets whichever build
this names, and choosing it is theirs — the same rule as listing a page.

## Your pages

```bash
curl https://api.mindblown.ai/me/games \
  -H "Authorization: Bearer $TOKEN"
```

Each row carries the page's `slug`, `title`, `play_url`, `state`, and its play
counts — `play_count`, `unique_players`, `last_played_at`. There is no separate
stats call; the numbers are here.

The history of one page:

```bash
curl https://api.mindblown.ai/me/games/GAME_ID/versions \
  -H "Authorization: Bearer $TOKEN"
```

## What runs inside a published page

Every page we serve carries one object of ours, `window.mindblown`, injected at
publish time. You do not add it and you cannot opt out of it.

Through it a page learns **who is looking** (their handle, and a stable id to
keep their things under), says **when a run starts and ends**, reports anything
else that happened, and reads the visitor's own **sound switch**.

**The whole of it is documented at <https://mindblown.ai/sdk.md>.** Read that
before you write a page that has runs, scores, saved progress or a name in it.

It is a separate document on purpose: it ships from the same build as the script
it describes, so it can neither get ahead of the code nor fall behind it. This
file is served from the API and would drift by a deploy.

## The creator's public page

A published page is live at its own URL immediately. Whether it also appears on
the person's public page is a second, separate choice, and it is theirs:

```bash
curl -X PATCH https://api.mindblown.ai/me/games/GAME_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"listed": true}'
```

Do not list a page without asking the person first.

## What you should tell the person

Two things, before you publish for them:

1. **A published page is public.** Anyone with the link can open it. There is no
   private mode. Do not publish anything they would not put on the open web.
2. **It is on their account.** They signed in; the page is theirs, not yours.
   They can see it and manage it without you.

## What we will not host

Read this line first, because it is the one that is easy to get backwards: **we
do not judge whether the content is good.** Not the style, not the fidelity, not
the subject, not whether it looks finished. That is the person's call and it is
never ours. A page that renders is a page we host.

What we refuse is harm, which is a different axis:

- **Impersonation.** A page presenting itself as a real company, product,
  person, or publication it is not — their name, their branding, their domain.
- **Deception that operates.** Sign-in screens, payment forms, or account
  pages that collect credentials or card details under a false identity.
  Whether it is "only a mock" does not change what it does to whoever opens it.
- **Fabricated records** shown as genuine: receipts, invoices, transcripts,
  test results, reviews, screenshots of messages nobody sent.
- **Malware.** Anything whose purpose is to attack, mine, hijack, or track the
  person who opened it.
- **Content targeting a private individual** — their address, their photographs,
  material made to harass them.
- **Anything illegal where the page is served.**

We remove these on report, and repeat offences end the account. Everything we
remove, we keep the bytes for, so a mistaken removal can be undone.

If you are unsure whether a page crosses one of these, publish nothing and ask
the person. You will usually be asking about impersonation, and they will
usually know.

## Limits

| | |
|---|---|
| Page size | 200 MB per version, all files together |
| Files | 2,000 per version |
| Publishes | 60 per hour per account |
| Reads | 600 per hour per account |

Every response carries `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A `429`
carries `Retry-After` in seconds.

## Errors

```json
{ "error": { "code": "entry_not_in_files", "message": "entry names index.html, which was not uploaded" } }
```

| Code | Status | What to do |
|---|---|---|
| `authorization_pending` | 400 | Keep polling. The human has not finished signing in. |
| `slow_down` | 400 | Double your polling interval. |
| `expired_token` | 400 | The device code lapsed. Ask for a new one. |
| `invalid_grant` | 400 | The refresh token is finished. Start the device flow. |
| `no_token` | 401 | Send `Authorization: Bearer <access_token>`. |
| `token_rejected` | 401 | The token was refused or names no account. Refresh it, then sign in again. |
| `not_multipart` | 415 | Send the upload as `multipart/form-data`. |
| `no_files` | 400 | Attach at least the entry file. |
| `entry_not_in_files` | 400 | Upload the file named by `entry`. |
| `bad_path` | 400 | A file name is absolute, contains `..`, or is not portable. |
| `duplicate_path` | 400 | Two uploaded files have the same path. |
| `too_many_files` | 400 | Over 2,000 files. |
| `payload_too_large` | 413 | Over 200 MB. Shrink the assets. |
| `rate_limited` | 429 | Over the publish ceiling. The message says how long to wait. |
| `blocked_origin` | 422 | A file loads from a CDN. `render.missing` names the URLs; publish them with the page. |
| `storage_unavailable` | 503 | Ours. Publishing is off on this deployment; nothing was written. |
| `token_check_failed` | 502 | Ours, not yours. Wait and retry with the same token. |

A page that uploads cleanly but does not render is **not** in this table. It
answers `422` with a `render` object, because nothing about the request was
wrong — see [If the page does not render](#if-the-page-does-not-render).
