Skip to main content

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 documented here.

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​

Its own public addressAnyone with the link opens the app, with no login.
Reads its boardEvery asset on the board and its sub-boards, with previews, colors, tags, statuses, custom fields and uploader names. Nothing outside the board.
SearchesKeyword search and AI search inside the board.
Runs a backendAn optional worker.js for anything the browser cannot do, such as rendering a printable sheet.
Remembers per visitorFavorites 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​

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). It is not served as a file.
  • A workspace can have at most 10 apps.

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:

RequestReturns
playbook/api/boards/{board}The board: title, description, asset_count.
playbook/api/boards?depth=allThe 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=1The 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 inside the board.
playbook/api/ai_search?query=…AI search 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 for the full shape and pagination.

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. Load a page of assets once, and poll rarely if at all.

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​

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.

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​

RequestWhat it does
GET /v1/{org}/hosted_appsThe 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​

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​

ResponseMeaning
403 hosted_apps_disabledHosted apps are not enabled for the workspace. Ask Playbook support.
403 or 404 on the boardThe token's owner cannot edit that board, or the board token is wrong.
422The 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 appToo 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.
502The app could not be changed right now. Send the same request again.