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
writescope 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 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
An app is a set of text files with no build step.
index.htmlis 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.jsis 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 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 inside the board. |
GET /v1/{org}/ai_search?query=… | AI search 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 for the full shape and pagination.
Rules that keep an app working
- Every URL is relative. Write
playbook/token,app.js,styles.css, never/playbook/tokenor/app.js. An app does not always sit at the root of its address, and a leading slash points outside it. - 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. - Ask for a new token on a 401. The token lasts an hour. Fetch
playbook/tokenagain once, then retry. - Do not keep image URLs.
display_urland thumbnail URLs are signed and expire. Read the board again instead of storing them. - Key browser storage by the app's path, for example
`favorites:${location.pathname}`. Apps can share an origin. - Mind the API allowance. Every call the page makes counts against the workspace's API quota, for every visitor. 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:
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
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
| 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
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/tokenitself. Start from the code below. - It can call only the Playbook API, and only to read:
GETrequests underhttps://api.playbook.com/v1/{org}/, plusPOST /v1/{org}/access_tokens, which returns a token for the page. Playbook adds the credential and limits it to the app's board; send noAuthorizationheader. 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
| 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. |