# Hosted Apps

A hosted app is a small web app that shows **one board and its sub-boards** to anyone with the link: a gallery, a portfolio, a catalogue, a lookbook. You, or your AI assistant, write it as plain static files. Playbook hosts it and gives it a public address. All data stays in Playbook: the app has no database, and reads the board through the same [REST API](/docs/api.md) documented here.

## Prerequisites[​](#prerequisites "Direct link to Prerequisites")

* **`write` scope** and permission to edit the board the app shows.
* **Hosted apps enabled for your workspace.** Until Playbook support enables it, every call below answers `403 hosted_apps_disabled`.

## App capabilities[​](#app-capabilities "Direct link to App capabilities")

|                            |                                                                                                                                                  |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Its own public address** | Anyone with the link opens the app, with no login.                                                                                               |
| **Reads its board**        | Every asset on the board and its sub-boards, with previews, colors, tags, statuses, custom fields and uploader names. Nothing outside the board. |
| **Searches**               | Keyword search and AI search inside the board.                                                                                                   |
| **Runs a backend**         | An optional `worker.js` for anything the browser cannot do, such as rendering a printable sheet.                                                 |
| **Remembers per visitor**  | Favorites and settings in the browser's `localStorage`.                                                                                          |

Everything on the board is visible to visitors, unapproved assets included. Confirm the board with its owner before deploying: putting an app on a board publishes that board.

**Coming soon:**

* Uploads from visitors into the app's board.
* Comments and approvals from the app.
* An option to show approved assets only.
* Your own domain for an app.

## The files[​](#the-files "Direct link to The files")

An app is a set of text files with no build step.

* `index.html` is required.
* Allowed types: `.html` `.css` `.js` `.mjs` `.json` `.svg` `.txt` `.webmanifest`.
* At most **50 files** and **1 MB** in total. Images, video and fonts are not part of an app: images come from Playbook, fonts from a CDN.
* Paths are plain names joined by `/` and `.`, with no leading slash: `app.js`, `lib/grid.js`.
* A top-level `worker.js` is the app's optional backend ([below](#an-optional-backend)). It is not served as a file.
* A workspace can have at most **10 apps**.

## How the page gets its data[​](#how-the-page-gets-its-data "Direct link to How the page gets its data")

The page fetches the relative URL `playbook/token`. Playbook's standard backend answers it:

```
{

  "token": "<read-only token, valid for one hour>",

  "api": "https://api.playbook.com",

  "org": "my-org",

  "board": "<token of the app's board>"

}
```

With that token the page calls the REST API straight from the browser, in the `Authorization` header as a Bearer token. The token allows these calls and nothing else, all limited to the app's board and its sub-boards:

| Request                                                       | Returns                                                                                            |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `GET /v1/{org}/boards/{board}`                                | The board: `title`, `description`, `asset_count`.                                                  |
| `GET /v1/{org}/boards?depth=all`                              | The board and every sub-board under it. `GET /v1/{org}/boards/{token}/children` returns one level. |
| `GET /v1/{org}/assets?nested_assets=true&per_page=100&page=1` | The assets of the board and its sub-boards. Leave `nested_assets` out for the board alone.         |
| `GET /v1/{org}/assets/{token}`                                | One asset.                                                                                         |
| `GET /v1/{org}/search?query=…`                                | [Keyword search](/docs/guides/search.md) inside the board.                                         |
| `GET /v1/{org}/ai_search?query=…`                             | [AI search](/docs/guides/ai_search.md) inside the board.                                           |

Each asset carries `token`, `title`, `description`, `media_type`, `display_url` (the full-size render), `thumbnails` (`url`, `width`, `height` each), `primary_width`, `primary_height`, `colors`, `tags`, and for video `stream_url`. See [Fetching Data](/docs/guides/fetching_data.md) for the full shape and pagination.

## Rules that keep an app working[​](#rules-that-keep-an-app-working "Direct link to Rules that keep an app working")

1. **Every URL is relative.** Write `playbook/token`, `app.js`, `styles.css`, never `/playbook/token` or `/app.js`. An app does not always sit at the root of its address, and a leading slash points outside it.
2. **Navigate with the hash.** A path that matches no file is a 404, so keep views in `location.hash` (`#/item/abc`), not in the path.
3. **Ask for a new token on a 401.** The token lasts an hour. Fetch `playbook/token` again once, then retry.
4. **Do not keep image URLs.** `display_url` and thumbnail URLs are signed and expire. Read the board again instead of storing them.
5. **Key browser storage by the app's path**, for example `` `favorites:${location.pathname}` ``. Apps can share an origin.
6. **Mind the API allowance.** Every call the page makes counts against the workspace's [API quota](/docs/guides/mcp.md#plans-and-limits), for every visitor. Load a page of assets once, and poll rarely if at all.

## A starter[​](#a-starter "Direct link to A starter")

Two files are a complete app.

`index.html`:

```
<!doctype html>

<html lang="en">

  <head>

    <meta charset="UTF-8" />

    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>Gallery</title>

    <style>

      body { margin: 0; padding: 24px; font: 16px/1.5 system-ui, sans-serif; }

      #grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }

      img { display: block; width: 100%; height: auto; }

    </style>

  </head>

  <body>

    <h1 id="title"></h1>

    <p id="notice" hidden>The board did not load. Reload the page to try again.</p>

    <main id="grid"></main>

    <script type="module" src="app.js"></script>

  </body>

</html>
```

`app.js`:

```
let session // { token, api, org, board }



async function api(path) {

  for (let attempt = 0; ; attempt++) {

    if (!session) {

      const answer = await fetch('playbook/token') // relative: no leading slash

      if (!answer.ok) throw new Error('No token')

      session = await answer.json()

    }

    const response = await fetch(`${session.api}/v1/${session.org}/${path}`, {

      headers: { Authorization: `Bearer ${session.token}` },

    })

    // The token lasts an hour: ask for a new one once.

    if (response.status === 401 && attempt === 0) {

      session = null

      continue

    }

    if (!response.ok) throw new Error(`Playbook answered ${response.status}`)

    return response.json()

  }

}



try {

  const { data: assets } = await api('assets?nested_assets=true&per_page=100')

  const { data: board } = await api(`boards/${session.board}`)

  document.title = board.title

  document.querySelector('#title').textContent = board.title



  const images = assets.filter(

    (asset) => asset.media_type?.startsWith('image/') && asset.thumbnails?.length

  )

  document.querySelector('#grid').append(

    ...images.map((asset) => {

      // The smallest thumbnail that still fills a column.

      const thumbs = [...asset.thumbnails].sort((a, b) => a.width - b.width)

      const thumb = thumbs.find((t) => t.width >= 440) || thumbs.at(-1)

      const image = document.createElement('img')

      image.src = thumb.url

      image.alt = asset.description || asset.title

      image.width = asset.primary_width

      image.height = asset.primary_height

      image.loading = 'lazy'

      return image

    })

  )

} catch {

  document.querySelector('#notice').hidden = false

}
```

## Deploy[​](#deploy "Direct link to Deploy")

**Over MCP**, call `deploy_app` with the board's token, a `slug`, a `name`, and `files`: the contents of every file as text, keyed by path. See [Connect AI Assistants via MCP](/docs/guides/mcp.md).

**Over the API:**

```
curl -X POST "https://api.playbook.com/v1/my-org/hosted_apps" \

  -H "Authorization: Bearer YOUR_TOKEN" \

  -H "Content-Type: application/json" \

  -d '{

    "hosted_app": {

      "slug": "acme-lookbook",

      "name": "Acme Lookbook",

      "board_token": "BOARD_TOKEN",

      "files": {

        "index.html": "<!doctype html>...",

        "app.js": "let session..."

      }

    }

  }'
```

```
{

  "data": {

    "slug": "acme-lookbook",

    "name": "Acme Lookbook",

    "url": "<the app's public address>",

    "board_token": "BOARD_TOKEN",

    "enabled": true,

    "created_at": "2026-10-12T10:00:00Z",

    "updated_at": "2026-10-12T10:00:00Z"

  }

}
```

The app is live at `url` as soon as the call returns. Use the address from the response; do not build it from the slug.

The `slug` is the app's address: lowercase letters, digits and hyphens, at most 63 characters, starting with a letter or a digit. It is unique across all of Playbook, so make it specific.

**To update an app, deploy again with the same slug.** A deploy replaces the whole file set, so send every file each time. The files of a deployed app cannot be read back: keep the source.

## List, switch off, delete[​](#list-switch-off-delete "Direct link to List, switch off, delete")

| Request                                                                        | What it does                                                                                                   |
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `GET /v1/{org}/hosted_apps`                                                    | The workspace's apps. MCP: `list_apps`.                                                                        |
| `PATCH /v1/{org}/hosted_apps/{slug}` with `{"hosted_app": {"enabled": false}}` | Switches the app off: its address answers 404 and it can no longer read the board. `true` switches it back on. |
| `DELETE /v1/{org}/hosted_apps/{slug}`                                          | Takes the app offline and deletes it. The slug becomes free. MCP: `delete_app`.                                |

## An optional backend[​](#an-optional-backend "Direct link to An optional backend")

Most apps need none. Add a top-level `worker.js` only for something the page cannot do in the browser, such as rendering a printable sheet as one finished document.

`worker.js` is a JavaScript module that exports a `fetch` handler. It runs for every request that matches no file, and sees paths relative to the app, such as `/playbook/token`.

* **It replaces the standard backend**, so it must answer `GET /playbook/token` itself. Start from the code below.
* **It can call only the Playbook API**, and only to read: `GET` requests under `https://api.playbook.com/v1/{org}/`, plus `POST /v1/{org}/access_tokens`, which returns a token for the page. Playbook adds the credential and limits it to the app's board; send no `Authorization` header. Any other request answers 403.
* **Limits per request:** 50 ms of CPU time and 20 outgoing requests. No environment variables, no storage.

The standard backend, with your workspace slug filled in:

```
const ORG = 'my-org'

const ORIGIN = 'https://api.playbook.com'

const API = ORIGIN + '/v1/' + ORG



export default {

  async fetch(request) {

    const { pathname } = new URL(request.url)

    if (request.method !== 'GET' || pathname !== '/playbook/token') {

      return new Response('Not found', { status: 404 })

    }



    const upstream = await fetch(API + '/access_tokens', {

      method: 'POST',

      headers: { 'Content-Type': 'application/json' },

      body: '{}',

    })

    if (!upstream.ok) {

      return Response.json({ error: 'Playbook did not issue a token' }, { status: 502 })

    }

    const { data } = await upstream.json()

    return Response.json(

      { token: data.access_token, api: ORIGIN, org: ORG, board: data.board_token },

      { headers: { 'Cache-Control': 'no-store' } }

    )

  },

}
```

## Errors[​](#errors "Direct link to Errors")

| Response                    | Meaning                                                                                                                                                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `403 hosted_apps_disabled`  | Hosted apps are not enabled for the workspace. Ask Playbook support.                                                                                                                                         |
| `403` or `404` on the board | The token's owner cannot edit that board, or the board token is wrong.                                                                                                                                       |
| `422`                       | The message says what to fix: a missing `index.html`, a file type or path that is not allowed, too many or too large files, a slug that is malformed or taken by another workspace, or the limit of 10 apps. |
| `502`                       | The app could not be changed right now. Send the same request again.                                                                                                                                         |
