# Connect AI Assistants via MCP

Playbook ships an official **MCP (Model Context Protocol)** server that lets AI assistants — Claude Code, Codex, Claude Desktop, Cursor, ChatGPT, and any other MCP-aware client — read and modify your Playbook workspace using natural language. Ask the assistant to "find every approved logo on the Brand board," "tag these screenshots as `mobile-onboarding`," or "upload this batch of URLs to the Marketing board" and it will call the right Playbook API for you.

The server is hosted at `https://mcp.playbook.com/mcp` — nothing to install and **no token to create, copy, or paste**. You connect it once, a Playbook consent screen opens in your browser, you click Allow, and the client holds an OAuth token from then on.

The server is built on top of the same v1 REST API documented in this site, so anything an MCP-connected agent can do, you can also do directly via HTTP — and vice versa.

## How it works[​](#how-it-works "Direct link to How it works")

```
AI assistant  ⇄  MCP client  ⇄  Playbook MCP server  ⇄  Playbook v1 REST API
```

1. **MCP client** advertises a list of "tools" exposed by the Playbook MCP server.
2. When the user asks a question, the assistant decides which tool to call, fills in the arguments, and the MCP client invokes the tool over JSON-RPC.
3. The Playbook MCP server translates each tool call into one or more authenticated requests against the Playbook v1 REST API.
4. The response is filtered (large/internal fields stripped) and returned to the assistant as text the model can reason about.

## Plans and limits[​](#plans-and-limits "Direct link to Plans and limits")

The MCP server is available on **every plan, including Free** — there is nothing to upgrade to connect it. Access follows Playbook's standard API rules, metered per organization:

* **Free** — up to 1,000 API calls per month. Each MCP tool call is one or more API calls; when the monthly quota is used up, calls return an error until it resets.
* **Pro and up** — higher monthly quotas (20,000 included on Pro) with metered overage.

You connect once in the browser and stay connected: the client holds a short-lived OAuth token that refreshes automatically, so the connection persists across sessions on any plan.

## Claude Code[​](#claude-code "Direct link to Claude Code")

### With the Playbook plugin (recommended)[​](#with-the-playbook-plugin-recommended "Direct link to With the Playbook plugin (recommended)")

The plugin ships the server configuration and five skills that teach Claude the common Playbook workflows, building and hosting an app on a board among them. If you installed it from Claude's directory on the web or desktop, it is already in Claude Code. Otherwise run these two commands inside Claude Code:

```
/plugin marketplace add playbook-labs/mcp-plugins

/plugin install playbook@playbook-plugins
```

Then run `/mcp`, pick **playbook-creative**, and choose **Authenticate**. Your browser opens the Playbook consent screen — click **Allow** and you're connected.

The same two steps work from your shell, without the interactive picker:

```
claude plugin marketplace add playbook-labs/mcp-plugins

claude plugin install playbook@playbook-plugins
```

### Without the plugin[​](#without-the-plugin "Direct link to Without the plugin")

One command, then approve in the browser:

```
claude mcp add --transport http playbook https://mcp.playbook.com/mcp
```

Run `/mcp`, pick **playbook**, choose **Authenticate**, and click **Allow** on the Playbook consent screen.

By default this registers the server for the current project. Pass `--scope user` to make it available in every project:

```
claude mcp add --transport http --scope user playbook https://mcp.playbook.com/mcp
```

Or add it by hand to `.mcp.json` at your project root, or to `~/.claude.json` — note there is no `headers` block:

```
{

  "mcpServers": {

    "playbook": {

      "type": "http",

      "url": "https://mcp.playbook.com/mcp"

    }

  }

}
```

## Claude on the web and desktop[​](#claude-on-the-web-and-desktop "Direct link to Claude on the web and desktop")

### From the Claude directory[​](#from-the-claude-directory "Direct link to From the Claude directory")

Playbook is listed in Claude's connector directory. Open [claude.ai/directory/playbook](https://claude.ai/directory/playbook), connect Playbook there, and approve the Playbook consent screen. Nothing has to be pasted, and once it is connected Playbook is available in Claude on the web, in the desktop app, and on mobile.

### With the Playbook plugin (recommended)[​](#with-the-playbook-plugin-recommended-1 "Direct link to With the Playbook plugin (recommended)")

The plugin is the connector plus the skills that teach Claude the common Playbook workflows. Install one or the other, not both.

Open the [Playbook plugin in Claude's directory](https://claude.ai/customize/plugins/id/485b0112-50a6-4307-81fd-37e600aeca4e%40anthropic-plugin-directory?via=directory), sign in, and install it. Then connect it on the plugin's **Connectors** tab and approve the Playbook consent screen. A plugin installed this way also appears in Claude Code the next time a session starts.

The same plugin is also published from our repository, where updates arrive without waiting for directory review: open **Customize > Plugins**, choose **Add**, then **Add marketplace**, enter `playbook-labs/mcp-plugins`, and install **Playbook** from the list.

### As a custom connector[​](#as-a-custom-connector "Direct link to As a custom connector")

Open **Customize > Connectors**, choose **Add custom connector**, and paste `https://mcp.playbook.com/mcp`. Click **Connect** and approve. Leave the Advanced settings (OAuth client ID/secret) blank.

On Team and Enterprise plans an Owner adds the connector once for the organization, under **Organization settings > Connectors**; members then connect it from their own settings.

If you're on a client that only speaks stdio, bridge to the hosted server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which runs the same browser sign-in:

```
{

  "mcpServers": {

    "playbook": {

      "command": "npx",

      "args": ["-y", "mcp-remote", "https://mcp.playbook.com/mcp"]

    }

  }

}
```

## Codex[​](#codex "Direct link to Codex")

### With the Playbook plugin[​](#with-the-playbook-plugin "Direct link to With the Playbook plugin")

The plugin adds the server and the same five skills. Two commands:

```
codex plugin marketplace add playbook-labs/mcp-plugins

codex plugin add playbook@playbook-plugins
```

Approve the Playbook consent screen when Codex opens it.

### Without the plugin[​](#without-the-plugin-1 "Direct link to Without the plugin")

Two commands:

```
codex mcp add playbook --url https://mcp.playbook.com/mcp

codex mcp login playbook
```

`codex mcp login` opens the Playbook consent screen in your browser; click **Allow**. Confirm it took with:

```
codex mcp list
```

`codex mcp add` writes to `~/.codex/config.toml`. The equivalent hand-written entry is:

```
[mcp_servers.playbook]

url = "https://mcp.playbook.com/mcp"
```

You still run `codex mcp login playbook` after editing the file by hand — the login stores the OAuth tokens, which never live in `config.toml`.

note

`codex mcp add --url` and `codex mcp login` require a recent Codex CLI. Check with `codex --version`, and upgrade if Codex rejects the `--url` flag.

## Cursor[​](#cursor "Direct link to Cursor")

One click: **[Add Playbook to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=playbook\&config=eyJ1cmwiOiJodHRwczovL21jcC5wbGF5Ym9vay5jb20vbWNwIn0=)** — Cursor opens with the server pre-filled.

Or do it by hand: Settings → **MCP** → "Add new MCP server", or edit `~/.cursor/mcp.json` directly. No auth header:

```
{

  "mcpServers": {

    "playbook": {

      "url": "https://mcp.playbook.com/mcp"

    }

  }

}
```

Cursor shows the server as needing login — click **Login** (or **Needs authentication**) to open the Playbook consent screen.

## VS Code[​](#vs-code "Direct link to VS Code")

One click: **[Add Playbook to VS Code](vscode:mcp/install?%7B%22name%22%3A%22playbook%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.playbook.com%2Fmcp%22%7D)** — VS Code opens with the server pre-filled.

The same install from the command line:

```
code --add-mcp '{"name":"playbook","type":"http","url":"https://mcp.playbook.com/mcp"}'
```

VS Code prompts you to sign in when the server first starts; approve the Playbook consent screen.

## ChatGPT[​](#chatgpt "Direct link to ChatGPT")

Turn on developer mode first: **Settings** → **Security and login** → **Developer mode**. Then open [chatgpt.com/plugins](https://chatgpt.com/plugins), select the plus button, and fill in:

* **URL:** `https://mcp.playbook.com/mcp`
* **Authentication:** OAuth — leave the client ID and secret blank, ChatGPT registers itself automatically

Connect it and approve the Playbook consent screen.

## Authentication[​](#authentication "Direct link to Authentication")

The MCP server is an OAuth 2.1 resource server. On the first request the client gets a `401` carrying a `WWW-Authenticate` challenge, discovers Playbook's authorization server, registers itself ([RFC 7591 dynamic client registration](https://datatracker.ietf.org/doc/html/rfc7591)), and runs an authorization-code flow with PKCE. All of that is automatic — the only thing you see is the consent screen.

What that means in practice:

* **No token to manage.** Nothing to generate, paste into a config file, rotate, or accidentally commit.
* **The assistant acts as you.** It sees exactly the workspaces, boards, and assets your Playbook account can see, and its writes are attributed to you.
* **Scopes are `read` and `write`.** `read` covers browsing boards, assets, comments, and documentation; `write` covers uploads, moves, edits, deletes, comments, and board changes. The consent screen lists what it is asking for.
* **Revoke at any time** from the Playbook app — revoking ends the assistant's access without touching your API tokens.

### Static API tokens[​](#static-api-tokens "Direct link to Static API tokens")

The MCP server also accepts a Playbook API token as a static `Authorization: Bearer` header. Use it where nobody can approve a consent screen — CI, scripts, and headless agents. For an assistant you use interactively, the browser sign-in above is simpler.

```
claude mcp add --transport http playbook https://mcp.playbook.com/mcp \

  --header "Authorization: Bearer YOUR_TOKEN"
```

It is the same token that works against the [v1 REST API](/docs/api.md); [create one](/docs/getting_started.md#how-to-get-access) in the Playbook app.

## Data handling[​](#data-handling "Direct link to Data handling")

* The assistant sends the server only its tool calls: a tool name and its arguments. Your conversation is not sent.
* The server passes each call to the Playbook v1 REST API as you and returns the result to the assistant.
* Playbook records usage analytics for each tool call in PostHog, linked to your Playbook user: the tool name, how long it took, whether it failed, and its arguments with passwords, upload credentials, note text and descriptions removed. Tool results are not recorded.
* Playbook's [privacy policy](https://www.playbook.com/privacy/) applies.

## Available tools[​](#available-tools "Direct link to Available tools")

All tools require the `read` scope. Tools marked **`write`** additionally require the `write` scope.

Every tool except `list_organizations` takes an `organization` (workspace slug), and it is **optional**: leave it out and the server resolves it from your token. Pass it when your account has more than one workspace — the server will not choose for you, it answers with the slugs to pick from.

### Read tools[​](#read-tools "Direct link to Read tools")

| Tool                      | What it does                                                                                                                                                   |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_organizations`      | List the workspaces you have access to, with each one's remaining permalink quota.                                                                             |
| `list_members`            | List workspace members, or resolve a name to the token the uploader filters take.                                                                              |
| `list_boards`             | List boards in a workspace, with optional search and hierarchy depth.                                                                                          |
| `get_board`               | Fetch a single board's details by token.                                                                                                                       |
| `list_board_children`     | List the direct child boards of a parent.                                                                                                                      |
| `list_board_descendants`  | List every board nested under a parent, at any depth, in one paginated call.                                                                                   |
| `resolve_share_link`      | Turn a share URL of your workspace, or a board's own address in the app, into the board token behind it.                                                       |
| `list_assets`             | List assets by board/subtree or uploader, with page or stable full-crawl cursor pagination. Takes `fields` to return only the columns you need.                |
| `get_asset`               | Full details of one asset, including approval and upload status (see [Async ingest semantics](#async-ingest-semantics)).                                       |
| `get_assets`              | Full details for up to 100 assets in one call — the batch form of `get_asset`. Reports unresolved tokens in `not_found` instead of failing.                    |
| `list_group_children`     | The assets inside a group (a stack of variants, or a palette), paginated. Listings and searches return only top-level rows.                                    |
| `list_asset_versions`     | An asset's version history, newest first, with the current head version number.                                                                                |
| `list_custom_fields`      | The workspace's option custom fields and their options, including the built-in Status field. Typed date/person/URL fields are not listed.                      |
| `search_assets`           | Keyword search across filename / title / tags, filtered by uploader, status, or field value. Scopes to a board, or a board and its sub-boards.                 |
| `ai_search`               | AI-powered visual and semantic search. Scopes to a board, or a board and its sub-boards.                                                                       |
| `get_comments`            | Read comments on an asset or board.                                                                                                                            |
| `get_asset_permalinks`    | Read assets' permanent public URLs, optionally rendered at a given width/height.                                                                               |
| `get_asset_previews`      | Render images at a size you choose and get a short-lived URL each — how an assistant actually looks at an asset (see [Looking at assets](#looking-at-assets)). |
| `get_asset_download_urls` | A signed URL of each asset's original file — the uploaded bytes, not a render — valid about a day. Up to 25 assets a call; each counts as one API call.        |
| `list_documentation`      | Index the Playbook API docs and guides available to the assistant.                                                                                             |
| `read_documentation`      | Read a specific docs page (e.g. `upload`, `webhooks`, `search`). Lets the assistant ground answers in the official docs without hallucinating.                 |

### Write tools — assets[​](#write-tools--assets "Direct link to Write tools — assets")

| Tool                                      | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `upload_from_url`  **`write`**            | Ingest a single asset by public URL. Returns immediately with a skeleton; Playbook fetches the bytes asynchronously.                                                                                                                                                                                                                                                                                                                                                      |
| `upload_from_urls`  **`write`**           | Ingest up to 100 assets in one call by public URL — all assets land on the same board, asynchronously.                                                                                                                                                                                                                                                                                                                                                                    |
| `create_upload_url`  **`write`**          | Step 1 of 2 for bytes you already hold: a one-time storage address plus the exact request to make.                                                                                                                                                                                                                                                                                                                                                                        |
| `finish_upload`  **`write`**              | Step 2 of 2: turn bytes already sent to storage into an asset.                                                                                                                                                                                                                                                                                                                                                                                                            |
| `create_upload_urls`  **`write`**         | Batch form of `create_upload_url` (1–100 files); returns a `batch_id` and echoes each item's `uuid`.                                                                                                                                                                                                                                                                                                                                                                      |
| `finish_uploads`  **`write`**             | Batch form of `finish_upload`; pass the `batch_id` so the quota reservation is released.                                                                                                                                                                                                                                                                                                                                                                                  |
| `create_note`  **`write`**                | Add a note to a board — a text asset with optional background colour and card height (synchronous).                                                                                                                                                                                                                                                                                                                                                                       |
| `create_colors`  **`write`**              | Add up to 100 colour swatches (hex or Pantone); two or more merge into a palette (synchronous).                                                                                                                                                                                                                                                                                                                                                                           |
| `group_assets`  **`write`**               | Merge assets into a group (variants, or a palette when every child is a colour).                                                                                                                                                                                                                                                                                                                                                                                          |
| `ungroup_assets`  **`write`**             | Pull assets out of their group; emptying a group removes the group asset.                                                                                                                                                                                                                                                                                                                                                                                                 |
| `move_assets`  **`write`**                | Bulk-move up to 1000 assets to a target board.                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `copy_assets`  **`write`**                | Bulk-copy up to 1000 assets to a target board (async).                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `set_asset_approval`  **`write`**         | Approve or hide up to 1000 assets across boards, optionally scheduling when hidden assets become visible again.                                                                                                                                                                                                                                                                                                                                                           |
| `change_asset_tags`  **`write`**          | Add and/or remove tags on an asset.                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `update_asset_status`  **`write`**        | Set an asset's status (e.g. `Approved`, `In Review`).                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `update_asset`  **`write`**               | Update title, description, tags, status, or board for one asset.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `create_asset_version`  **`write`**       | Upload a public URL as a new version of an existing asset, replacing its bytes. Needs versioning enabled.                                                                                                                                                                                                                                                                                                                                                                 |
| `revert_asset`  **`write`**               | Revert an asset to an earlier `version_number` (head moves forward). Needs versioning enabled.                                                                                                                                                                                                                                                                                                                                                                            |
| `edit_asset_version_comment`  **`write`** | Edit the comment on an existing asset version. Needs versioning enabled.                                                                                                                                                                                                                                                                                                                                                                                                  |
| `delete_asset`  **`write`**               | Soft-delete an asset (recoverable from trash).                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `share_asset`  **`write`**                | Create or return the shareable link for one asset.                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `retry_video_processing`  **`write`**     | Start an encode for a video whose `stream_url` is stuck at `null`, whether the last attempt failed, died on the way to the encoder, or was never made. Workspaces that encode on upload need it only for a failed or stalled encode; elsewhere it is how an encode is started — reading an asset through MCP never starts one. Call it once, not in a loop: it cannot tell a running encode from a dead one, so a second call while one is under way queues a second job. |
| `add_asset_permalinks`  **`write`**       | Publish assets at a permanent public URL. Plan-capped; over the cap the whole batch is refused.                                                                                                                                                                                                                                                                                                                                                                           |
| `remove_asset_permalinks`  **`write`**    | Revoke permalinks. A later re-add mints a different URL, and the published file stays fetchable until the public copy is purged.                                                                                                                                                                                                                                                                                                                                          |

### Import tools[​](#import-tools "Direct link to Import tools")

| Tool                                    | What it does                                                                                                                                                                                                                                               |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_import_sources`                   | The Google Drive and Dropbox connections your Playbook user made in the web app, each with its `provider`, plus the `connect_url` to make one. There is no tool that connects a service.                                                                   |
| `import_from_google_drive`  **`write`** | Import up to 20 Drive folders (URLs or IDs, private folders included), each into a sub-board named after it, asynchronously. Re-importing into the same board skips unchanged files. Never bills unconsented storage overage — refuses or cancels instead. |
| `import_from_dropbox`  **`write`**      | Import up to 20 Dropbox folders (address-bar URLs, paths or `id:` keys; not shared links), each into a sub-board named after it, asynchronously. Re-importing into the same board skips unchanged files. Same storage rules.                               |
| `get_import`                            | An import's status and file counts — poll until `completed` or `canceled`. See [Import from Google Drive](/docs/guides/google_drive_import.md) and [Import from Dropbox](/docs/guides/dropbox_import.md).                                                  |

### Write tools — boards[​](#write-tools--boards "Direct link to Write tools — boards")

| Tool                         | What it does                                                                                                                                                                                  |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_board`  **`write`**  | Create a new board, optionally nested under a parent.                                                                                                                                         |
| `update_board`  **`write`**  | Update a board's title, description, or parent.                                                                                                                                               |
| `delete_board`  **`write`**  | Delete a board and all of its contents.                                                                                                                                                       |
| `share_board`  **`write`**   | Create or update a share link, including expiry, download, and password controls.                                                                                                             |
| `publish_board`  **`write`** | Create or update a published page with the same optional controls, plus a design `template`. Without one, a board that is not live and has no design yet gets `standard` so it can be edited. |

### Published page design[​](#published-page-design "Direct link to Published page design")

These edit the design of a board published with a `template`. Writes also need page layout API writes enabled for the workspace. See [Published Page Design](/docs/guides/published_page_design.md).

| Tool                                 | What it does                                                                                                            |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `get_page_elements_catalog`          | Template ids, element type names, legal colours and typographies. Pass `element_types` to get the props of those types. |
| `get_page_layout`                    | The stored page design, verbatim, with its `version_token`. `slots: null` is normal for an unedited page.               |
| `add_page_element`  **`write`**      | Insert an element into a slot or a section column.                                                                      |
| `update_page_element`  **`write`**   | Replace an element — the whole `props` object, not one field.                                                           |
| `move_page_element`  **`write`**     | Move an element within or between slots, or into/out of a section.                                                      |
| `delete_page_element`  **`write`**   | Remove an element. An id that is already gone answers `converged: true`.                                                |
| `reorder_page_slot`  **`write`**     | Set a slot's top-level order from its exact id list.                                                                    |
| `apply_page_operations`  **`write`** | Up to 50 add/update/move/delete/reorder edits, all-or-nothing.                                                          |
| `update_page_settings`  **`write`**  | Set or clear the page's background colour and typography.                                                               |

### Hosted apps[​](#hosted-apps "Direct link to Hosted apps")

A hosted app is a small public web app over one board that the assistant writes and Playbook hosts. These need hosted apps enabled for the workspace. See [Hosted Apps](/docs/guides/hosted_apps.md).

| Tool                      | What it does                                                                                              |
| ------------------------- | --------------------------------------------------------------------------------------------------------- |
| `list_apps`               | The workspace's apps: slug, name, public address, the board each shows, and whether it is enabled.        |
| `deploy_app`  **`write`** | Put an app live, or replace the files of the app with the same slug. Takes the complete file set as text. |
| `delete_app`  **`write`** | Take an app offline and delete it.                                                                        |

### Write tools — custom fields[​](#write-tools--custom-fields "Direct link to Write tools — custom fields")

| Tool                               | What it does                                                                                    |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| `create_custom_field`  **`write`** | Create a custom field with its options. A name that already exists **replaces** its option set. |
| `delete_custom_field`  **`write`** | Delete a custom field and its values.                                                           |

### Write tools — comments[​](#write-tools--comments "Direct link to Write tools — comments")

| Tool                          | What it does                                   |
| ----------------------------- | ---------------------------------------------- |
| `create_comment`  **`write`** | Post a top-level comment on an asset or board. |

## Looking at assets[​](#looking-at-assets "Direct link to Looking at assets")

An asset row carries `display_url`, the full-size render — 5000px wide is ordinary. It is the wrong thing to fetch just to see what an asset looks like, and too large for most vision models.

Ask `get_asset_previews` for a size instead (1024 wide is plenty). It renders images only — a video, PDF or note comes back as a URL that will not load — and the URLs die at the end of the next hour, so they are for looking now, never for storing.

When the URL has to keep working — a document, a page, a hand-off — publish the asset with `add_asset_permalinks` and read it back with `get_asset_permalinks`, which also takes a width and height. Permalinks are public to anyone holding the URL and capped per plan; `list_organizations` reports the remaining quota, and both permalink write tools return it after their writes.

## Async ingest semantics[​](#async-ingest-semantics "Direct link to Async ingest semantics")

The `upload_from_url` and `upload_from_urls` tools return immediately with **skeleton assets** — the asset rows exist but the bytes are still being fetched. The MCP response wraps each asset as `{ asset, status: "queued", note: "..." }` to nudge the assistant against reporting the upload as "done" prematurely.

To detect completion, call `get_asset` on the returned token — or `get_assets` with all of them at once, which is one call per poll rather than one per asset for a batch of a hundred. The upload is finished when `is_skeleton: false` AND one of:

* `media_type` populated → success
* `source_error` non-null → failed (the message is the latest worker error)
* `is_link: true` (only when `as_link: true` was passed) → kept as a bare link, no fetch attempted

See the [Upload guide → Async Ingest Semantics](/docs/guides/upload.md#async-ingest-semantics) for the same contract documented from the REST side, including recommended polling cadence.

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

Tool errors are returned as MCP `isError: true` text content with a human-readable message. The server maps Playbook API errors as follows:

| Status | Tool message                                                                                                                                                                                                                                                                |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 401    | "Authentication failed" — sign in again from your client's MCP menu.                                                                                                                                                                                                        |
| 403    | "Permission denied — check your token's scopes (`read`/`write`)."                                                                                                                                                                                                           |
| 404    | The API's own message when it names the missing identifier (`"Not found: Assets not found: abc123"`); otherwise guidance to re-read the workspace slug and the board or asset token, and a note that retrying unchanged will fail the same way.                             |
| 406    | `"Invalid input: <message from the API>"` (used by batch URL ingest validation).                                                                                                                                                                                            |
| 409    | `"Conflict: <message> (<code>; retryable=<bool>)"` — the code tells conflicts apart, e.g. `stale_document` (re-read first) versus `lock_wait_timeout` (retry as-is).                                                                                                        |
| 422    | `"Validation error: <message from the API>"`.                                                                                                                                                                                                                               |
| 429    | From the Playbook API: `"Rate limited: Rate limit of 120 requests per 60s exceeded. Retry in <n>s. (rate_limit_exceeded)"` — wait the stated seconds. A 429 without that body (e.g. from a proxy): `"Rate limited — too many requests. Wait up to a minute and try again."` |
| 5xx    | `"Playbook API error: <status> <statusText>"`, followed by the API's message and code when the response carries them.                                                                                                                                                       |

Credentials are never echoed back to the assistant, even when the upstream error body would contain one.
