# 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 reads the board through its own address, with relative URLs and no key. Playbook answers two paths on every app:

* `GET playbook/app` returns `{ "board": "<token of the app's board>" }`.
* `GET playbook/api/<path>` returns the REST API's answer to `GET /v1/{org}/<path>`, with the same query string. Playbook adds the credential, limited to the app's board and its sub-boards.

These are the calls it allows:

| Request                                                      | Returns                                                                                           |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `playbook/api/boards/{board}`                                | The board: `title`, `description`, `asset_count`.                                                 |
| `playbook/api/boards?depth=all`                              | The board and every sub-board under it. `playbook/api/boards/{token}/children` returns one level. |
| `playbook/api/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.        |
| `playbook/api/assets/{token}`                                | One asset.                                                                                        |
| `playbook/api/search?query=…`                                | [Keyword search](/docs/guides/search.md) inside the board.                                        |
| `playbook/api/ai_search?query=…`                             | [AI search](/docs/guides/ai_search.md) inside the board.                                          |

A page holds at most 100 items. Answers are cached for up to five minutes, so a change to the board can take that long to show. Each visitor may make 300 requests a minute to an app and cause 30 uncached reads, and an app may make 120 uncached reads a minute; more answer `429` with `Retry-After`. Once the workspace's API allowance is spent, reads answer `402`.

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/api/assets`, `app.js`, `styles.css`, never `/playbook/api/assets` 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. **Never call `api.playbook.com` from the page, and never put a token in a file.** The page has no key, and every file of the app is public.
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.** Reads that miss the cache count against the workspace's [API quota](/docs/guides/mcp.md#plans-and-limits). 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`:

```
// Relative: no leading slash. Playbook adds the credential.

async function api(path) {

  const response = await fetch(`playbook/api/${path}`)

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

  return response.json()

}



try {

  const app = await (await fetch('playbook/app')).json()

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

  const { data: board } = await api(`boards/${app.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 is not under `playbook/`, and sees paths relative to the app, such as `/sheet`.

* **It can call only the Playbook API**, and only to read: `GET` requests under `https://api.playbook.com/v1/{org}/`. 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. Its reads are not cached and count toward the app's 120 a minute. No environment variables, no storage.

```
const API = 'https://api.playbook.com/v1/my-org'



export default {

  async fetch(request) {

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

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

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

    }

    const answer = await fetch(`${API}/assets?nested_assets=true&per_page=1`)

    const { pagy } = await answer.json()

    return Response.json({ assets: pagy.total_count })

  },

}
```

## 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 (nothing may sit under `playbook/`), too many or too large files, a slug that is malformed or taken by another workspace, or the limit of 10 apps. |
| `429` from the app          | Too many requests from one visitor, or too many uncached reads by the app. Wait `Retry-After` seconds.                                                                                                                                           |
| `402` from `playbook/api/`  | The workspace's API allowance is spent.                                                                                                                                                                                                          |
| `502`                       | The app could not be changed right now. Send the same request again.                                                                                                                                                                             |
