# POS Apps > Build custom apps that run inside Pancake POS: add actions, blocks, pages and hooks to the POS interface, and read/write data through the Open API. Apps are private to a single shop. --- Source: https://crm.pancake.in/developers/apps/overview.md # Overview & design POS Apps let a shop build its own apps (hand-written or vibe-coded with AI tools), **upload them to Pancake POS and use them right inside the POS interface**. An app only runs in the shop that installed it; it is never published publicly. An app can: - Add **actions** to existing action menus, for example "Push to ERP" in the order action menu. - Attach to orders, products, purchases, exports and warehouse transfers with the same extension points and the same code shape. - Insert **blocks** into existing screens, for example a "Customer debt" box in the order form. - Add **its own pages** to the POS menu, for example a "Debt reconciliation" screen. - Run **hooks** before or after an operation, for example blocking an order save when the customer exceeds a credit limit. - **Read and write shop data** through the [Open API](/developers/api/en), with exactly the permissions of the current user. An app **cannot** modify, hide or replace existing POS UI or actions. ## Terminology | Term | Meaning | |---|---| | **App** | A shop's code bundle: a manifest plus built files. Every upload creates a version. | | **Extension** | A part of an app attached to one place in POS. An app has many extensions. | | **Extension point / target** | A place POS exposes to apps, for example `order.details.action` or `purchase.index.action`. See [Extension points](/developers/apps/en/targets). | | **Action extension** | Adds an item to an existing action menu. | | **Block extension** | Inserts a box into an existing screen. | | **Page extension** | The app's own page, opened from the POS menu. | | **Hook** | Logic that runs before/after an existing operation, with no UI. | | **should_render** | A condition that decides whether an action or block is shown. | | **Worker UI** | How blocks and modals render: app code runs isolated in a Web Worker and only describes the UI with `pos-*` elements; POS draws it with real components. | | **App token** | A short-lived token POS issues so the app can call the Open API on behalf of the current user. | ## Architecture ```text POS page (pos.pages.fm) App sandbox (separate domain) ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │ Extension points │ postMessage│ Hidden iframe │ │ ├─ Block / modal (Worker UI) │◄──────────►│ └─ Web Worker │ │ │ drawn with POS components │ │ ├─ App code + Preact │ │ ├─ App page (iframe) │ │ └─ POS runtime (pos, DOM) │ │ └─ Hooks, display conditions │ │ Page iframe: the app's SPA │ │ Bridge: context data, │ │ └─ SDK client (connect) │ │ token, toasts, navigation │ │ fetch ──► Open API (app token) │ └──────────────────────────────────┘ └──────────────────────────────────┘ ``` Principles: 1. **App code never runs on the POS domain.** Apps run in a web worker or an iframe on a sandbox domain dedicated to each app, so they cannot read the POS session, cookies or UI. 2. **POS draws the UI; the app only describes it.** For blocks and modals, the app builds a tree of `pos-*` elements. POS receives that description and renders it with its own components, so the UI matches POS exactly and the app never touches the page. 3. **All data goes through the Open API**, using an app token that carries the current user's permissions. 4. **Targets, components and context data are a versioned contract** (`api_version` in the manifest). POS keeps backward compatibility for the support period of each version. ## SDK Apps do not implement the communication with POS themselves. It is split into two parts: - **POS runtime**: POS loads it into the worker before the app's code. It provides the global `pos` object, the minimal DOM for Worker UI, the `pos-*` elements, the postMessage channel, app tokens and the Open API client. The runtime is not part of the app bundle, so POS can update or patch it without apps redeploying. - **SDK packages** installed in the app project: | Package | Role | |---|---| | `@pancake-pos/ui-extensions` | Types for `pos` and `pos-*` elements, the `defineExtension` helper; the `connect()` client for iframe pages | | `@pancake-pos/ui` | Theme and POS-styled components for iframe pages | | `@pancake-pos/app-cli` | The `pos-app` command: sign in, try in POS, check code, build, upload | | `@pancake-pos/eslint-plugin` | Code rules; editors show problems as you type, and `pos-app check` uses the same rules | | `create-pos-app` | Creates a starter project with an `AGENTS.md` for AI tools | Package versions follow `api_version` (package `2026.1.x` for `2026-01`). `pos-app check` validates the manifest and catches mistakes AI tools often make (using the real DOM, plain HTML tags, undeclared methods or domains). Details: [SDK & CLI](/developers/apps/en/sdk). ## Choosing a presentation | Need | Presentation | Why | |---|---|---| | A box or modal inside an existing screen | **Worker UI** | Looks like POS, lightweight, popovers/modals work normally | | A one-step operation with no UI | **Headless** (worker only) | Shows a toast when done, opens nothing | | Button visibility conditions, before/after-save hooks | **Headless** | No UI | | A complex standalone screen: charts, maps, drag and drop, third-party UI libraries | **Iframe** | Free to use any web library | Rule of thumb: *inside a POS screen → Worker UI; a screen of its own → iframe; no UI → headless.* ### How Worker UI works A web worker has no DOM. The POS runtime loads a minimal DOM into the worker, just enough for Preact or plain JS to build a tree of `pos-*` elements. Whenever the tree changes, the runtime records the changes (nodes added or removed, properties or text changed) and sends them to POS as one batch per render pass. POS validates each element and draws it with real components. When the user clicks or types, POS sends the event into the worker and the app's code receives the value in `event.detail`. ```text Worker (app code) POS setState → Preact re-renders on the minimal DOM → runtime batches the changes ──────────► validate → draw with POS components onChange(event.detail) ◄───────────────────────── user clicks, types ``` Consequences: - **State lives in the app's code** (`useState`, variables…). POS only keeps the rendered UI, never the app's state. - Each time an extension is shown it runs in its own worker. Closing the modal or leaving the screen stops the worker and its state is lost; persist anything long-lived through the Open API or the app's own server. - Inputs stay responsive while the app is busy: POS shows what the user types immediately, then notifies the app. - The runtime is built into the worker; apps install no extra library to talk to POS. - Only elements from the [component catalog](/developers/apps/en/components) can be used. Unknown elements are not rendered and the extension reports an error. - There is no `window`, `document.querySelector`, `localStorage`, `canvas` or DOM measurement. Chart and third-party UI libraries do not run in the worker; use POS-provided components or move that part to an iframe page. ## Security model | Risk | Mitigation | |---|---| | The app uses the POS login session | App code runs on a separate sandbox domain and cannot read POS cookies or tokens | | The app reads or modifies the POS UI | The worker has no real DOM; POS only renders valid `pos-*` elements; iframes are cross-origin | | Customer-entered data contains malicious code | Worker UI accepts no raw HTML; for iframes, damage is limited to the app token's permissions | | Staff use an app to exceed their permissions | App tokens always carry the current user's permissions; only users with the configuration management permission can upload them | | The app sends data elsewhere | The app can only call, and only show images from, domains declared in `network.allowed_origins`, which admins see when activating | | The app slows down or freezes POS | Lazy loading, timeouts, killing stuck workers, size limits, an emergency kill switch | | A token leaks | Tokens expire after 15 minutes, are bound to one shop, one user and one app, and are revoked as soon as the app is disabled | ## Data access Apps use **the existing Open API**: same endpoints, format and pagination. The difference is the credential: - No static API key in code. POS issues an **app token** automatically while the app runs. - **App permissions = the current user's permissions ∩ the limits the admin sets for the app** (warehouses, hidden fields, read-only…). - Every write is audited with the real user and the app. Details: [Data access](/developers/apps/en/data-access). ## App lifecycle 1. A shop member with the configuration management permission uploads a bundle with the CLI. Each upload creates a **version**. 2. POS validates the manifest: valid targets, existing files, sizes within limits. 3. The admin reviews the data access and external domains the app requests, confirms the shop is responsible for the app, then **activates** the version on the [Custom apps](/developers/apps/en/manage-apps) page. 4. By default every shop member sees the app; limiting it by department is not yet available on the management page. 5. The admin can **roll back** to an earlier version or **disable** the app at any time. Disabling revokes all app tokens in use. ## Limits | Item | Limit | |---|---| | Bundle per version | ≤ 10 MB, at most 1,000 files | | Extension module (Worker UI, headless) | ≤ 256 KB gzip per module | | Extensions per app | 30 | | Domains in `network.allowed_origins` | 10 | | Active apps per shop | 10 | | Versions kept per app | The latest 20, plus the active one | | Total app storage per shop | 500 MB | | Worker UI elements per extension | 2,000 | | Worker UI updates | 60 per second; an extension that exceeds this for 3 seconds in a row is stopped | | `should_render` timeout | 300 ms | | `before_submit` hook timeout | 2 seconds by default (`timeout_ms`), 5 seconds max | | `after_save` hook and `headless` action run time | 30 seconds | | Open API request rate | Under 30 reads (`GET`) and under 100 writes per second, per app + user + shop + endpoint | | App token lifetime | 15 minutes, never beyond the user's login session (renewed by the SDK) | | Error log per app | The latest 500 errors, kept for 7 days | ## Support scope - **POS web only.** The mobile app does not run extensions yet. - UI hooks only run when the operation happens on POS web, at the place where the hook is attached. Records created or changed through the Open API, the mobile app, bulk updates or marketplace sync do **not** go through hooks. Rules that must always hold belong on the server. - Apps do not run in the background. Scheduled jobs and webhook processing need the shop's own backend (see [Data access](/developers/apps/en/data-access#background-tasks)). ## Roadmap | Phase | Scope | |---|---| | PoC | Sandbox, app tokens, order detail block, order action menu item, iframe page, a sample app written by AI | | Phase 1 | Upload, versions, rollback, visibility permissions, audit, rate limits, component catalog v1, SDK, CLI | | Phase 2 | `before_submit` hook, Worker UI pages (`render: "worker"`), custom print templates, custom columns, customer blocks, per-app webhooks and service keys | | Phase 3 | Consider the mobile app, install one app across shops of the same owner | --- Source: https://crm.pancake.in/developers/apps/getting-started.md # Getting started ## Requirements - A Pancake account that is a member of the shop and has the **configuration management** permission (the owner or someone granted it). - Node.js 22 or later (24 recommended). - Basic Preact knowledge (or React for iframe pages). If you vibe-code, connect your AI tool to the MCP server as described in [Build with AI](/developers/apps/en/ai-guide), or point it at `/developers/apps/llms-full.txt`. ## 1. Create a project ```bash npx create-pos-app my-app cd my-app npm install ``` Default layout: ```text my-app/ ├─ pos-app.json # manifest: permissions, external domains, extensions ├─ extensions/ # code that runs in the worker (Worker UI or headless) │ ├─ order-block.tsx │ └─ push-erp.ts ├─ page/ # iframe page (optional), Vite + React │ ├─ index.html │ ├─ tsconfig.json # separate TypeScript config for React │ └─ src/main.tsx ├─ public/ # images for pos-image (optional) ├─ AGENTS.md # rules for AI tools, generated by the template ├─ eslint.config.js # code rules, editors show problems as you type ├─ package.json └─ tsconfig.json ``` The template declares these packages, installed by `npm install`: - `@pancake-pos/ui-extensions`: the SDK, with types for the `pos` object and the `pos-*` elements. - `@pancake-pos/ui`: theme and POS-styled components for iframe pages. - `@pancake-pos/app-cli`: the `pos-app` command. - `@pancake-pos/eslint-plugin`: code rules, shared with `pos-app check`. Package details, TypeScript setup and the full CLI reference: [SDK & CLI](/developers/apps/en/sdk). ## 2. Declare extensions Example: add a block to the order form and an item to the order action menu: ```json { "handle": "my-app", "name": "My app", "version": "0.1.0", "api_version": "2026-01", "access": { "methods": ["GET", "PUT"] }, "extensions": [ { "target": "order.details.block", "id": "order-block", "module": "extensions/order-block.tsx" }, { "target": "order.details.action", "id": "push-erp", "label": "Push to ERP", "presentation": "headless", "module": "extensions/push-erp.ts" } ] } ``` See every field in [Manifest](/developers/apps/en/manifest) and every target in [Extension points](/developers/apps/en/targets). ## 3. Try it inside POS In POS, go to **Settings › Advanced › Custom apps**, click **Connect CLI** › **Create token** and run the command it shows. The command is shown only once; the same dialog lists the shop's active tokens so you can revoke the ones no longer in use (screenshots and details: [Manage apps › Connect the CLI](/developers/apps/en/manage-apps#connect-the-cli)). It looks like: ```bash npx @pancake-pos/app-cli login --shop 123 --token ``` Then run: ```bash npx pos-app dev # watch and build, serve at http://localhost:5174 ``` On the same management page, click **Development mode** and enter `http://localhost:5174` (see [Manage apps › Development mode](/developers/apps/en/manage-apps#development-mode)). While it is on, POS loads the app from `localhost` **in your browser only**; everyone else in the shop keeps using the active version. When you edit code, POS reloads the extensions without reloading the page. Use Chrome or Edge: Safari may block an `https` page from loading `http://localhost`. In development mode, `pos.api` can call the Open API only after the app has been uploaded at least once (`npx pos-app deploy`, no activation needed), and it uses the `access` of the latest uploaded version. Deploy again after changing `access`. After each such deploy, reload the POS page so POS gets a new token. Development mode runs against the real data of the open shop, so try a test shop first. To see the app as a staff member with narrow permissions, deploy and activate it on a test shop and sign in with that account. While you write code, an editor with the ESLint extension shows problems as you type. Run `npx pos-app check` to validate the manifest as well. ## 4. Build and upload ```bash npx pos-app deploy ``` This command: 1. Runs `pos-app check`: validates the manifest and catches common code mistakes (see [SDK & CLI](/developers/apps/en/sdk#pos-app-check)). 2. Builds each module in `extensions/` into a standalone ES module and builds `page/` with Vite. 3. Runs final checks: valid targets, existing files, sizes within the [limits](/developers/apps/en/overview#limits). 4. Uploads and creates a **new version** in the inactive state, then prints the version id. ## 5. Activate On the **Custom apps** page, a shop admin: 1. Clicks **Versions** on the app's row (for a new app: **Review & activate**), then **Activate** on the uploaded version. 2. Reviews the data access (`access`), external servers (`network.allowed_origins`) and placements the app requests, then clicks **Activate**. Users see the extensions on their next page load. ![The access review shown before activating a version](/static/developers/apps/manage/en/review.webp) By default every shop member sees the app; limiting it by department is not yet available on the management page. Going back to an older version, turning the app off and reading the error log: [Manage apps in POS](/developers/apps/en/manage-apps). ## Next steps - [Manage apps in POS](/developers/apps/en/manage-apps): activate, turn on and off, read errors. - [Build with AI](/developers/apps/en/ai-guide): the MCP server and sample prompts. - [Examples](/developers/apps/en/examples): a complete sample app. - [Runtime API](/developers/apps/en/runtime-api): the `pos` object. - [UI components](/developers/apps/en/components): the `pos-*` elements. --- Source: https://crm.pancake.in/developers/apps/manage-apps.md # Manage apps in POS Everything about a shop's apps is managed on the **Custom apps** page in POS: reviewing access and activating versions, turning apps on and off, going back to an older version, reading errors, connecting the CLI and turning on development mode. The page is only shown to accounts with the **configuration management** permission, on shops where Pancake has enabled POS Apps. ## Open the page 1. In POS, click **Settings** (the gear icon in the left sidebar). 2. Under **Advanced**, choose **Custom apps**. ![The Custom apps entry under Advanced on the Settings page](/static/developers/apps/manage/en/menu.webp) Direct link: `https://pos.pancake.vn/shop//setting/custom-apps`. ## App list ![The shop's custom apps](/static/developers/apps/manage/en/list.webp) | Column or button | Meaning | |---|---| | **App** | App name and the `handle` from `pos-app.json`. | | **Status** | Switch that turns the app on or off. An app with no activated version shows **Review & activate** instead. | | **Active version** | The version everyone in the shop is using. | | **Versions** | Uploaded versions; activate one or go back to an older one. | | **Error log** | Runtime errors POS recorded for the app. | | **Delete app** | Only shown while the app is off; deletes the app with all its versions (see [Delete an app](#delete-an-app)). | | **Development mode** | Loads the build served by `pos-app dev` on your machine, in this browser only. | | **Connect CLI** | Creates tokens for the `pos-app` command and revokes the shop's tokens. | An app shows up in the list after its first upload (`pos-app deploy` or the MCP `deploy` tool). ## Review and activate a version Every upload creates an **inactive** version; users see nothing until an admin activates it. 1. Click **Versions** on the app's row (or **Review & activate** for a new app). The list shows versions with their upload time, source (`cli` or `mcp`), the note given at deploy time and who approved them. Each app keeps its latest 20 versions plus the active one; older versions are deleted when a new one is uploaded. ![The app's versions, with an Activate button on the new version](/static/developers/apps/manage/en/versions.webp) 2. Click **Activate** on the version to use. POS shows what the app asks for: - **Data operations**: `Read`, `Create`, `Update`, `Delete` from `access.methods`. Write operations are highlighted in orange. - **Data limits**: warehouses, order sources, order statuses, hidden fields, product categories… from `access`. Without them, the app follows the user's own permissions. - **External servers**: domains in `network.allowed_origins` the app may call. - **Self-hosted page**: the `url` of a self-hosted `app.page`, if any. - **Placements**: each extension and its target. ![The access review shown before activating a version](/static/developers/apps/manage/en/review.webp) 3. Check write operations and external servers carefully, tick the box confirming the app was developed by the shop and the shop is responsible for the data it reads and writes, then click **Activate**. POS records who approved it and when in the **Approved by** column. Users get the new version on their next page load. By default every member of the shop sees the app; the data it reads and writes stays within each person's own permissions (see [Data access](/developers/apps/en/data-access)). ## Go back to an older version Open **Versions** and click **Activate** on the older version. POS shows that version's access review again before switching. ## Turn an app off and on Use the switch in the **Status** column: - **Off**: every extension of the app disappears from POS and its app tokens are revoked immediately. Use it when the app misbehaves badly or data may have leaked. - **On again**: the app runs the activated version again, without another review. ## Delete an app Delete apps you no longer use to free up the shop's storage: 1. Turn the app off with the switch in the **Status** column. 2. Click **Delete app** on the app's row and confirm. POS deletes every version, uploaded bundle and the error log of the app; this cannot be undone. An app that is on cannot be deleted. To use it again, run `pos-app deploy` as for a new app (the old `handle` can be reused). ## Shop limits - **At most 10 apps on at a time.** There is no limit on uploaded apps; only apps that are on count. With 10 apps on, activating or turning on another one fails with "A shop can enable at most 10 apps"; turn one off to make room. - **At most 500 MB for all apps.** This is the total of the versions kept for every app, including apps that are off. Each app keeps its latest 20 versions plus the active one; older versions are deleted when a new one is uploaded. Above the quota, `pos-app deploy` fails with `storage_quota_exceeded`; [delete apps](#delete-an-app) you no longer use to free space. Other limits: [Overview › Limits](/developers/apps/en/overview#limits). ## Error log Click **Error log** to see errors POS recorded while the app ran on users' machines: time, extension (`id` in the manifest), error code and message. ![An app's error log](/static/developers/apps/manage/en/errors.webp) | Code | Meaning | |---|---| | `uncaught` | App code threw an error it did not catch. | | `timeout` | A module did not return in time (for example `before_submit` over `timeout_ms`). | | `module_timeout` | A module did not start in time. | | `render_error` | POS could not draw the UI the app sent. | | `hook_failed`, `should_render_failed`, `invalid_result` | A hook or `should_render` failed, or returned the wrong kind of result. | | `frame_timeout` | The app sandbox did not load (the network blocks the sandbox domain, or the sandbox is down). | POS keeps each app's 500 most recent errors for 7 days. Developers read the same log with the `get_errors` tool of the [MCP server](/developers/apps/en/ai-guide#mcp). ## Connect the CLI The `pos-app` command (and the MCP server) needs a token to upload apps to the shop. 1. Click **Connect CLI** › **Create token**. 2. Click the copy icon and run the command on your machine. The command is shown **once**; after closing the dialog you need a new token. ![The Connect CLI dialog: the login command and the active tokens](/static/developers/apps/manage/en/cli.webp) - A token lasts 30 days and stops working when its creator logs out of POS. - A token only covers developer work: uploading versions and reading apps, versions, errors and sample data. It **cannot** activate a version or change shop settings. - **Active tokens** lists everyone's tokens in the shop. Click **Revoke** when a token is no longer used or may have leaked. `pos-app logout` also revokes the token on that machine. ## Development mode Development mode lets you try the code you are writing inside real POS without affecting anyone else. 1. On your machine, run `npx pos-app dev` in the app folder (served at `http://localhost:5174` by default). 2. Click **Development mode**, enter the **Dev server address** and click **Enable development mode**. ![The Development mode dialog](/static/developers/apps/manage/en/dev-mode.webp) - Only this browser loads the development build; everyone else keeps the activated version. - When you change code, POS reloads the extension without reloading the page. - Open the dialog again and click **Disable development mode** when you are done. Details and notes about real data: [Getting started › Try it inside POS](/developers/apps/en/getting-started#3-try-it-inside-pos). ## FAQ **I cannot find Custom apps.** The account lacks the configuration management permission. If it has the permission and still cannot see it, contact Pancake. **I activated a version but do not see the app.** Reload POS. Check that the switch in **Status** is on and that the extension's `conditions` match the record you have open. **`pos-app deploy` fails with `storage_quota_exceeded`.** The shop has used its 500 MB for apps. [Delete apps](#delete-an-app) you no longer use, then deploy again. **Staff see the app but get a permission error.** The app uses the permissions of whoever is using it. Grant that person more permissions, or make the app handle `403` (see [Data access](/developers/apps/en/data-access)). **I found a platform bug or need a new feature.** Ask your AI tool to send a report with the `send_feedback` tool of the [MCP server](/developers/apps/en/ai-guide#report-bugs-and-request-features), or contact Pancake. --- Source: https://crm.pancake.in/developers/apps/ai-guide.md # Build with AI > This page is for people building apps with AI tools (Claude Code, Cursor, Codex, Copilot…) and for the AI tools themselves. ## Give your AI tool the context | Option | Use when | What the AI can do | |---|---|---| | **`pos-app` MCP server** (recommended) | Your AI tool supports MCP | Look up the docs, see the shop's real ids, check the app, try it in POS, read errors, deploy, report bugs to Pancake | | **`AGENTS.md`** | Every project created with `create-pos-app` | Read the mandatory rules on every change | | **`llms-full.txt`** | Tools without MCP, or chat on the web | Read all the docs at once | They work together: a `create-pos-app` project already has `AGENTS.md`; just add the MCP server. ## MCP `pos-app mcp` starts an [MCP](https://modelcontextprotocol.io) server so AI tools can look up the docs, see the shop's real metadata, check the app, read runtime errors and deploy without leaving the chat. It runs on your machine (stdio), in the app's project folder, with the account signed in through `pos-app login`. ### Connect the MCP server 1. Sign the CLI in once: in POS go to **Settings › Advanced › Custom apps**, click **Connect CLI › Create token** and run the command it shows (see [Manage apps › Connect the CLI](/developers/apps/en/manage-apps#connect-the-cli)). 2. Add the server to your AI tool, in the app's project folder (replace `123` with your shop id): ```bash # Claude Code claude mcp add pos-app -- npx @pancake-pos/app-cli mcp --shop 123 ``` Cursor (`.cursor/mcp.json` in the project): ```json { "mcpServers": { "pos-app": { "command": "npx", "args": ["@pancake-pos/app-cli", "mcp", "--shop", "123"] } } } ``` VS Code with Copilot (`.vscode/mcp.json` in the project): ```json { "servers": { "pos-app": { "type": "stdio", "command": "npx", "args": ["@pancake-pos/app-cli", "mcp", "--shop", "123"] } } } ``` ```toml # Codex: ~/.codex/config.toml [mcp_servers.pos-app] command = "npx" args = ["@pancake-pos/app-cli", "mcp", "--shop", "123"] ``` Claude Desktop (`claude_desktop_config.json`) does not run inside the project folder, so add `--cwd`: ```json { "mcpServers": { "pos-app": { "command": "npx", "args": ["@pancake-pos/app-cli", "mcp", "--shop", "123", "--cwd", "/path/to/my-app"] } } } ``` 3. Restart the AI tool and ask it to "list the pos-app tools". In Claude Code, `/mcp` shows whether the server is connected. ### Tools | Tool | Input | Returns | |---|---|---| | `search_docs` | `query` | Matching sections of these docs, as Markdown | | `get_target` | `target`, for example `purchase.details.action` | Context data schema, expected return value, manifest fields and a short example | | `list_shop_meta` | `resource` | Ids and names the app can use in `conditions` and code: statuses, tags, warehouses, product categories, order sources | | `sample_context` | `target`, optional record id | A realistic `pos.data` built from the shop's data, filtered by the app's `access` and the signed-in user's permissions | | `check_app` | none | The `pos-app check` result: each problem with file, line, rule id and a suggested fix | | `dev_start`, `dev_status` | none | Starts `pos-app dev`; returns the local URL, the POS link to open in development mode and build errors | | `get_errors` | `version` (`dev` by default), `since` | Runtime errors POS recorded for the app: target, message, stack trace, time | | `deploy` | `message` | Runs `check` and `build`, then uploads a new **inactive** version; returns the version and the link to the custom apps page | | `send_feedback` | `kind` (`bug` or `feature`), `title`, `description`, `include_errors` | Sends a POS Apps bug report or feature request to Pancake's POS support team; returns an `FB-…` id | - `deploy` never activates a version. An admin reviews access and activates it on the [Custom apps](/developers/apps/en/manage-apps#review-and-activate-a-version) page, exactly as with `pos-app deploy`. - If `version` in `pos-app.json` already exists, `deploy` fails with `duplicate_version`; bump `version` and deploy again. - Tools act as the signed-in user on the given shop. They never return app tokens or API keys, and they do not write shop data: `list_shop_meta` and `sample_context` only read. App code still reads and writes data through `pos.api`. ### Typical loop 1. `get_target` and `search_docs` to pick the right targets and types. 2. `list_shop_meta` and `sample_context` to write `conditions` and code against real ids and fields. 3. Write the code, then `check_app` until it reports no errors. 4. `dev_start`, try the app in POS, then `get_errors` to fix runtime errors. 5. `deploy` and ask an admin to activate the new version. ### Sample prompts ```text Create a POS app "Debt reminder": a block in the order form showing the customer's total debt, and a "Send reminder" button that calls https://crm.example.com/api/remind. Read-only access is enough. Use sample_context for real order data, run check_app until it is clean, then dev_start so I can try it in POS. ``` ```text The "Push to ERP" button in version 1.2.0 is failing. Read get_errors for that version, find the cause, fix it, bump the version and deploy with a note. ``` ```text Show the "Approve order" action only for orders waiting for confirmation that come from Facebook. Get the status and order source ids with list_shop_meta instead of hard-coding names. ``` ## Report bugs and request features When you hit a platform bug (runtime, components, SDK, CLI, MCP, docs, Open API) or need something that does not exist yet, ask your AI tool to send it to Pancake with the `send_feedback` tool: ```text Report to Pancake: pos-table does not show the date column when format is "date". ``` 1. The AI drafts a title and description (what happened, what you expected, steps to reproduce; or the need and the use case) and shows it to you first. 2. Only after you agree does it send. The report goes straight to Pancake's POS support channel; the AI gives you an `FB-…` id to quote when following up. Reports automatically include the CLI and runtime versions, OS, Node, the app handle and version, and the app's 5 most recent runtime errors (skip them with `include_errors: false`). Code and shop data are never sent; tokens pasted by mistake are masked before sending. Each shop can send up to 20 reports per day. ## Without MCP - All docs as Markdown: `/developers/apps/llms-full.txt`. Index: `/developers/apps/llms.txt`. Every page has a Markdown version at the same path with `.md`, for example `/developers/apps/targets.md`. - These LLM files are in English by default. Vietnamese versions live under `/developers/apps/vi/`, for example `/developers/apps/vi/llms-full.txt`. - The `create-pos-app` template generates an `AGENTS.md` with the content in the [last section](#sample-agents-md). Keep it in the project so AI tools always read the rules. Sample prompt: ```text Read https://pos.pages.fm/developers/apps/llms-full.txt. Create a POS app "Debt reminder": a block in the order form showing the customer's total debt, and a "Send reminder" button that calls https://crm.example.com/api/remind. Read-only access is enough. ``` ## Mandatory rules ### Runtime environment 1. Code in `extensions/` runs in a **Web Worker** with no real DOM. - Do not use `window`, `document.querySelector`, `document.getElementById`, `localStorage`, `sessionStorage`, `alert`, `confirm`, `canvas`, `getBoundingClientRect`. - Do not import UI libraries (antd, MUI, Tailwind, chart libraries, drag-and-drop libraries) or CSS files. - Use Preact (recommended) or plain JS to render `pos-*` elements into `document.body`. Do not use React in `extensions/`. - Do not install any library to connect to POS or render UI; the POS runtime is already in the worker. 2. Iframe pages (`page/`) are regular web apps: any library is allowed, but they must get `pos` from `connect()`, must not access `window.top`/`window.parent`, and must not use routes starting with `/_pos/` (reserved for POS). ### UI 3. Worker UI extensions use **only** elements from [UI components](/developers/apps/en/components). Do not use `div`, `span`, `p`, `img`, `table`, `button`, `input`. 4. Text must be inside `pos-text`, `pos-heading`, `pos-badge`, `pos-link`, `pos-button` or `pos-banner`. 5. Do not use `style` or `className`. Event values come from `event.detail`. 6. Display amounts with `pos-money` or a column with `format: 'money'`; do not format them yourself. ### Data 7. Get POS data only through `pos.api`, with paths like `/shops/${pos.shop.id}/...` and endpoints listed in the [Open API](/developers/api/en). 8. Never hardcode a shop id, API key or token; never send the app token elsewhere, except to the backend of a [self-hosted page](/developers/apps/en/runtime-api#self-hosted-pages); never put secrets in code. 9. Every method used in code (`POST`, `PUT`, `DELETE`) must be listed in `access.methods`. Every external domain (including `pos-image` sources) must be listed in `network.allowed_origins`. 10. Read the current record from `data.record` (`data.records` in bulk actions). Call the Open API with `record.system_id` for orders and `record.id` for products, purchases, exports and warehouse transfers. ### Modules and manifest 11. Every module has `export default async function (data)`. - Blocks, `modal`/`fullscreen` actions, `render: "worker"` pages: render into `document.body`. - `headless` actions: return `{ message?, refresh? }` or throw. - `should_render`: return a `boolean`, finish within 300 ms, no slow API calls. - `*.before_submit`: return `{ ok: true }` or `{ ok: false, message }`, finish within `timeout_ms`. 12. Every extension in `pos-app.json` has a unique `id`; `target` must exist in [Extension points](/developers/apps/en/targets). 13. Prefer wrapping modules with `defineExtension(target, fn)` from `@pancake-pos/ui-extensions` so the types of `data` and of the return value match the target. ### Quality 14. Wrap every API call in `try/catch`; show errors with `pos-banner` (in UI) or `pos.toast.show(..., { tone: 'error' })`. 15. Handle `403` (the user lacks permission) and `429` (retry later with an increasing delay). 16. Do not call the API on every render; use `useEffect` with explicit dependencies, pagination and `AbortController`. 17. Write the UI in the user's language (`pos.user.locale`). 18. After every change, run `npx eslint .` (or the `check_app` tool) and fix every problem. Do not add `eslint-disable` to silence a rule; `pos-app check` still blocks the deploy. ## Common mistakes | Wrong | Right | |---|---| | `
…
` | `…` | | ` set(e.target.value)} />` | ` set(e.detail)} />` | | `fetch('https://pos.pages.fm/api/v1/shops/123/orders?api_key=...')` | `` pos.api.get(`/shops/${pos.shop.id}/orders`) `` | | `localStorage.setItem(...)` in an extension | Store it through the Open API (CRM tables) or your own backend | | `alert('Done')` | `pos.toast.show('Done')` | | `data.order`, `data.purchase` | `data.record` (`data.resource` tells which resource) | | `` `/shops/${shopId}/purchases/${record.display_id}` `` | `` `/shops/${shopId}/purchases/${record.id}` `` | | `import { Table } from 'antd'` in an extension | `` | | Calling `PUT` while `access.methods` only has `GET` | Add `"PUT"` to `access.methods` | | `new Intl.NumberFormat(...).format(total) + ' VND'` | `` | ## Pre-deploy checklist - [ ] `pos-app.json` is valid: `handle`, a new `version`, `api_version`, no duplicate `id`. - [ ] `access.methods` covers every write request; no unnecessary permissions. - [ ] Every external domain is in `network.allowed_origins`. - [ ] Modules in `extensions/` use no real DOM, UI libraries or CSS. - [ ] No tokens, API keys or secrets in code. - [ ] Errors and loading states are handled. - [ ] `npx pos-app check` reports no errors. - [ ] Tested with `npx pos-app dev` (an account with the Settings permission), then deployed to a test shop and tried with a narrow-permission staff account. ## Sample AGENTS.md ````markdown # POS App This project is a custom app that runs inside Pancake POS. Docs: https://pos.pages.fm/developers/apps/llms-full.txt (read before changing code). ## Structure - pos-app.json: manifest (permissions, external domains, extensions). - extensions/: modules that run in a Web Worker and render pos-* elements with Preact. - page/: iframe page (React + Vite), gets `pos` from `connect()` in `@pancake-pos/ui-extensions/page`; routes starting with /_pos/ are reserved for POS. ## Rules - extensions/: no real DOM, window, localStorage, React, UI libraries or CSS. Only pos-* elements from the docs. - Text goes inside pos-text/pos-heading/pos-badge/pos-link/pos-button/pos-banner. Event values are in event.detail. - POS data only through pos.api with paths like /shops/${pos.shop.id}/... and Open API endpoints. - Never hardcode shop ids, API keys, tokens or secrets. - Write methods must be in access.methods; external domains must be in network.allowed_origins. - Modules: export default async function (data). Headless returns { message }; before_submit returns { ok } or { ok: false, message }. - Catch errors on every API call; handle 403 and 429. ## Commands - npx pos-app dev: try in POS (turn on Development mode on the custom apps management page); POS reloads when code changes. - npx eslint .: check the code after every change; do not add eslint-disable to silence rules. - npx pos-app check: validate the manifest and code; fix every error before deploying. - npx pos-app deploy: build, validate the manifest, upload a new version. - With the pos-app MCP server: get_target/list_shop_meta/sample_context before writing code, check_app after every change, get_errors after testing in POS, deploy when done (uploads an inactive version; an admin activates it). For a platform bug or a missing feature: draft a report, show it to the user, and call send_feedback only after they agree. ```` --- Source: https://crm.pancake.in/developers/apps/examples.md # Examples > Field names follow the [Open API](/developers/api/en). The **"ERP sync"** sample app contains: 1. A block in the order form showing the order's ERP sync status. 2. A headless "Push to ERP" action. 3. A "Warranty card" modal action with a form. 4. A hook that blocks saving orders above 20 million without a phone number. 5. An "ERP reconciliation" iframe page listing orders. 6. A bulk action that pushes the selected warehouse transfers to the ERP, showing that other resources work the same way. The ERP in this example is `https://erp.example.com`, managed by the shop, with CORS enabled for the app's sandbox domain. ## Manifest ```json { "handle": "erp-sync", "name": "ERP sync", "version": "1.0.0", "api_version": "2026-01", "access": { "methods": ["GET", "PUT"] }, "network": { "allowed_origins": ["https://erp.example.com"] }, "extensions": [ { "target": "nav.item", "id": "nav", "label": "ERP reconciliation", "page": "reconcile" }, { "target": "app.page", "id": "reconcile", "title": "ERP reconciliation", "render": "iframe", "entry": "page/index.html" }, { "target": "order.details.block", "id": "erp-status", "module": "extensions/erp-status.tsx", "conditions": { "mode": ["edit"] } }, { "target": "order.details.action", "id": "push-erp", "label": "Push to ERP", "presentation": "headless", "module": "extensions/push-erp.ts", "conditions": { "mode": ["edit"] } }, { "target": "order.details.action", "id": "warranty", "label": "Warranty card", "presentation": "modal", "module": "extensions/warranty.tsx", "conditions": { "mode": ["edit"] } }, { "target": "order.before_submit", "id": "require-phone", "module": "extensions/require-phone.ts", "timeout_ms": 1000, "on_error": "allow" }, { "target": "warehouse_transfer.index.selection-action", "id": "push-transfers", "label": "Push to ERP", "presentation": "headless", "module": "extensions/push-transfers.ts" } ] } ``` ## 1. ERP status block `extensions/erp-status.tsx` ```tsx import { render } from 'preact'; import { useEffect, useState } from 'preact/hooks'; type Status = { synced: boolean; erp_code?: string; synced_at?: string }; function ErpStatus({ systemId }: { systemId: number }) { const [status, setStatus] = useState(null); const [error, setError] = useState(null); const load = async () => { setError(null); try { const res = await fetch(`https://erp.example.com/api/orders/${systemId}`); if (!res.ok) throw new Error(`ERP returned ${res.status}`); setStatus(await res.json()); } catch (e) { setError(e.message); } }; useEffect(() => { load(); }, [systemId]); return ( Reload {error && {error}} {!error && !status && } {status && ( {status.synced ? 'Synced' : 'Not synced'} {status.erp_code && ERP code: {status.erp_code}} )} ); } export default async function (data) { render(, document.body); } ``` ## 2. Headless action "Push to ERP" `extensions/push-erp.ts` ```ts export default async function (data) { const shopId = pos.shop.id; const systemId = data.record.system_id; const { data: order } = await pos.api.get(`/shops/${shopId}/orders/${systemId}`); const res = await fetch('https://erp.example.com/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pos_order_id: systemId, customer_name: order.bill_full_name, total: order.total_price, items: order.items, }), }); if (!res.ok) throw new Error(`ERP rejected the order (${res.status})`); const { erp_code } = await res.json(); // Store the ERP code in the order note; see the Open API docs for updatable fields. const note = [order.note, `ERP: ${erp_code}`].filter(Boolean).join('\n'); await pos.api.put(`/shops/${shopId}/orders/${systemId}`, { note }); return { message: `Pushed to ERP: ${erp_code}`, refresh: true }; } ``` Thrown errors (`throw`) are shown by POS as error toasts. ## 3. Modal action "Warranty card" `extensions/warranty.tsx` ```tsx import { render } from 'preact'; import { useState } from 'preact/hooks'; function WarrantyForm({ systemId }: { systemId: number }) { const [serial, setSerial] = useState(''); const [expiresOn, setExpiresOn] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const submit = async () => { if (!serial) return setError('Enter the serial number'); setSaving(true); setError(null); try { const res = await fetch('https://erp.example.com/api/warranties', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pos_order_id: systemId, serial, expires_on: expiresOn }), }); if (!res.ok) throw new Error(`ERP returned ${res.status}`); pos.toast.show('Warranty card created'); pos.close(); } catch (e) { setError(e.message); } finally { setSaving(false); } }; return ( {error && {error}} setSerial(e.detail)} /> setExpiresOn(e.detail)} /> pos.close()}>Cancel Create card ); } export default async function (data) { render(, document.body); } ``` ## 4. Hook that blocks saving an order `extensions/require-phone.ts` ```ts const LIMIT = 20_000_000; export default async function ({ draft }) { if (draft.total_price > LIMIT && !draft.bill_phone_number) { return { ok: false, message: 'Orders above 20 million need the recipient phone number.' }; } return { ok: true }; } ``` If the shop hides `bill_phone_number` through `access.hidden_order_fields`, the field is always `undefined` and the hook will block every large order. Check `access` when writing hooks. ## 5. "ERP reconciliation" iframe page `page/src/main.tsx` (React + Vite) ```tsx import { createRoot } from 'react-dom/client'; import { useEffect, useState } from 'react'; import { connect } from '@pancake-pos/ui-extensions/page'; const pos = await connect(); function Reconcile() { const [page, setPage] = useState(1); const [orders, setOrders] = useState([]); const [total, setTotal] = useState(0); useEffect(() => { const controller = new AbortController(); pos.api .get(`/shops/${pos.shop.id}/orders`, { query: { page_number: page, page_size: 20 }, signal: controller.signal, }) .then((res) => { setOrders(res.data); setTotal(res.total_entries); }) .catch((e) => e.name !== 'AbortError' && pos.toast.show(e.message, { tone: 'error' })); return () => controller.abort(); }, [page]); useEffect(() => { pos.page.setTitle('ERP reconciliation'); pos.page.setPrimaryAction({ label: 'Sync again' }); return pos.on('primary-action', () => pos.toast.show('Syncing...', { tone: 'info' })); }, []); return ( {orders.map((o) => ( ))}
OrderCustomerTotal
{o.system_id} {o.bill_full_name} {o.total_price}
Page {page} / {Math.max(1, Math.ceil(total / 20))}
); } createRoot(document.getElementById('root')!).render(); ``` Iframe pages use free-form HTML/React; you can replace the table above with `@pancake-pos/ui` components to match the POS look. ## 6. Bulk action on warehouse transfers `extensions/push-transfers.ts` ```ts // warehouse_transfer.index.selection-action: records is WarehouseTransferRef[] export default async function ({ records }) { const res = await fetch('https://erp.example.com/api/stock-transfers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( records.map((t) => ({ pos_transfer_id: t.id, code: t.custom_id || String(t.display_id), from_warehouse_id: t.from_warehouse_id, to_warehouse_id: t.to_warehouse_id, status: t.status, })) ), }); if (!res.ok) throw new Error(`ERP rejected the transfers (${res.status})`); return { message: `Pushed ${records.length} transfers to the ERP`, refresh: false }; } ``` The module is the same as for orders; only the resource changes: `records` holds `WarehouseTransferRef`, whose `id` is used with the Open API (`/shops/{shop_id}/transfers/{id}`). Read line items through the Open API when the ERP needs them. --- Source: https://crm.pancake.in/developers/apps/manifest.md # Manifest (pos-app.json) `pos-app.json` lives at the project root. POS reads it on upload to learn which permissions the app needs, which domains it may call and where it attaches. ## Full example ```json { "handle": "debt-reconciliation", "name": "Debt reconciliation", "description": "Checks customer debt when creating orders and reconciles at the end of the day.", "version": "1.3.0", "api_version": "2026-01", "access": { "methods": ["GET", "POST", "PUT"], "warehouse_ids": null, "hidden_order_fields": ["bill_phone_number"] }, "network": { "allowed_origins": ["https://erp.example-customer.com"] }, "extensions": [ { "target": "nav.item", "id": "nav", "label": "Reconciliation", "page": "home" }, { "target": "app.page", "id": "home", "title": "Debt reconciliation", "render": "iframe", "entry": "page/index.html" }, { "target": "order.details.action", "id": "push-erp", "label": "Push to ERP", "presentation": "headless", "module": "extensions/push-erp.ts", "conditions": { "status": [1, 2] } }, { "target": "order.details.action", "id": "warranty", "label": "Create warranty card", "presentation": "modal", "title": "Warranty card", "module": "extensions/warranty-modal.tsx", "should_render": "extensions/warranty-should-render.ts" }, { "target": "order.details.block", "id": "debt", "module": "extensions/debt-block.tsx" }, { "target": "purchase.index.action", "id": "push-purchase-erp", "label": "Push to ERP", "presentation": "headless", "module": "extensions/push-purchase-erp.ts", "conditions": { "status": [1] } }, { "target": "order.before_submit", "id": "check-debt", "module": "extensions/check-debt.ts", "timeout_ms": 2000, "on_error": "allow" } ] } ``` ## Top-level fields | Field | Type | Required | Description | |---|---|---|---| | `handle` | string | Yes | App identifier within the shop, matching `^[a-z0-9][a-z0-9-]{1,39}$`. Used in the app page URL. Changing `handle` makes the next upload create a separate new app. | | `name` | string | Yes | Display name, up to 60 characters. | | `description` | string | No | Short description, up to 300 characters. Shown to the admin when activating. | | `version` | string | Yes | Semver, for example `1.3.0`. Every upload must use a version that does not exist yet. | | `api_version` | string | Yes | The POS contract version (`YYYY-MM`). It determines the available targets, components and context schemas. | | `access` | object | No | Limits on the data the app can use. See [access](#access). | | `network` | object | No | External domains the app may call. See [network](#network). | | `extensions` | array | Yes | Up to 30 extensions. See [extensions](#extensions). | ## access **Additional** limits on top of the current user's permissions. An app token never has more permissions than the user; `access` only narrows them further. | Field | Type | Default | Description | |---|---|---|---| | `methods` | string[] | `["GET"]` | Allowed HTTP methods: `GET`, `POST`, `PUT`, `DELETE`. When omitted the app is **read-only**. | | `warehouse_ids` | uuid[] | no limit | Only operate on these warehouses. Applies to orders, purchases, exports, warehouse transfers and stock by warehouse. | | `order_sources` | string[] | no limit | Only see orders from these sources. | | `hidden_order_sources` | string[] | none hidden | Hide orders from these sources. | | `order_statuses` | int[] | no limit | Only see orders in these statuses. | | `order_since` | datetime (ISO 8601) | no limit | Only see orders created from this time on. | | `hidden_order_fields` | string[] | none hidden | Remove these fields from order data returned to the app, including context data. | | `product_categories` | int[] | no limit | Only see products in these categories. | | `product_brands` | int[] | no limit | Only see products of these brands. | `null` or an omitted field means no additional limit (except `methods`). The limits that apply are the `access` of the active version. ## network | Field | Type | Description | |---|---|---| | `allowed_origins` | string[] | `https://` origins the app may call with `fetch`, up to 10, no wildcards. The POS Open API is always allowed and does not need to be listed. Also applies to `pos-image` sources. | Requests to origins not on the list are blocked by the browser. External servers must enable CORS for the app's sandbox domain (see [Data access](/developers/apps/en/data-access#calling-external-systems)). ## extensions ### Common fields | Field | Type | Required | Description | |---|---|---|---| | `target` | string | Yes | Where the extension attaches. See [Extension points](/developers/apps/en/targets). | | `id` | string | Yes | Unique within the app, matching `^[a-z0-9-]{1,40}$`. | | `module` | string | Depends on target | Source file path (`.ts`, `.tsx`, `.js`, `.jsx`). The CLI builds it into a standalone ES module. Required for Worker UI and headless targets. | ### Action fields Apply to `*.details.action`, `*.index.action` and `*.index.selection-action` of every resource. | Field | Type | Required | Description | |---|---|---|---| | `label` | string | Yes | Menu item label, up to 40 characters. | | `presentation` | string | Yes | `headless`, `modal`, `fullscreen` or `navigate`. See [action types](/developers/apps/en/targets#action-types). | | `title` | string | No | Modal title (`modal`, `fullscreen`). Defaults to `label`. | | `page` | string | With `navigate` | `id` of the `app.page` extension to open. | | `path` | string | No | Path inside the page for `navigate`, for example `/returns`. | | `conditions` | object | No | When to show the menu item, evaluated by POS. See [conditions](#conditions). | | `should_render` | string | No | A module returning `true`/`false` to decide whether to show the item. Use it when `conditions` is not enough. | ### Block fields Apply to `*.details.block`. | Field | Type | Required | Description | |---|---|---|---| | `conditions` | object | No | When to show the block. | | `should_render` | string | No | Same as for actions. | ### Page and menu fields | Target | Field | Description | |---|---|---| | `app.page` | `title` (required) | Page title. | | `app.page` | `render` (required) | `iframe` or `worker` (Worker UI pages are planned for phase 2). | | `app.page` | `entry` | The page's HTML file in the bundle, with `render: "iframe"`. Declare `entry` **or** `url`. | | `app.page` | `url` | Self-hosted page: an `https` address on your own server that POS embeds instead of a file in the bundle. No credentials, query or `#`; no Pancake domains, `localhost` or IP addresses. Changing `url` requires a new version. See [Self-hosted pages](/developers/apps/en/runtime-api#self-hosted-pages). | | `app.page` | `module` | Required when `render: "worker"`. | | `nav.item` | `label` (required) | Label in the POS menu, up to 30 characters. | | `nav.item` | `page` (required) | `id` of the `app.page` extension. | ### Hook fields Apply to `*.before_submit` and `*.after_save`. | Field | Type | Default | Description | |---|---|---|---| | `timeout_ms` | int | `2000` | `before_submit` only, at most `5000`. | | `on_error` | string | `allow` | `before_submit` only: `allow` lets the save continue when the hook fails or times out, `block` stops it. | ## conditions Keys are combined with **AND**; values in an array within one key are combined with **OR**. POS evaluates conditions while rendering, so menus never slow down. | Key | Type | Resources | True when | |---|---|---|---| | `mode` | string[] | All (detail form targets) | `create` (new, unsaved record) or `edit` (existing record). | | `status` | int[] | `order`, `purchase`, `export`, `warehouse_transfer` | The record status is in the list (status codes as in the Open API). | | `warehouse_ids` | uuid[] | `order`, `purchase`, `export`, `warehouse_transfer` | The record's warehouse is in the list; for a transfer, either the source or the destination warehouse. | | `tags_any` | (int \| string)[] | `order`, `product`, `purchase`, `export` | The record has at least one of the listed tags (tag IDs). | | `type` | int[] | `export` | The export type is in the list. | | `category_ids` | int[] | `product` | The product belongs to at least one of the listed categories. | | `order_sources` | string[] | `order` | The order source is in the list. | | `has_customer` | boolean | `order` | The order has / does not have a customer attached. | Using a key that does not apply to the target's resource is a validation error (`invalid_condition`). Example: only show for existing orders in status 1 or 2 that carry the VIP tag (ID 7): ```json { "mode": ["edit"], "status": [1, 2], "tags_any": [7] } ``` Example: only show for purchases that have been received (status 1) into one specific warehouse: ```json { "status": [1], "warehouse_ids": ["a52f…"] } ``` ## Manifest validation errors | Code | Cause | |---|---| | `invalid_handle` | `handle` has an invalid format. | | `invalid_field` | A field has an invalid value, for example it is too long, `access` has an unknown key, or `access.methods` is invalid. | | `duplicate_version` | `version` already exists. | | `unsupported_api_version` | `api_version` is no longer supported or does not exist. | | `unknown_target` | `target` does not exist in the declared `api_version`. | | `missing_field` | A field required by the target is missing. | | `module_not_found` | The `module`/`entry`/`should_render` file cannot be found. | | `bundle_too_large` | A module or the bundle exceeds the size limits. | | `storage_quota_exceeded` | The shop's total app storage would exceed 500 MB. | | `invalid_bundle` | The uploaded file is not a valid `.zip` package, or contains an unsafe file path. | | `upload_failed` | The bundle could not be stored; upload again. | | `invalid_origin` | An `allowed_origins` entry is not `https://` or uses a wildcard. | | `invalid_url` | The `app.page` `url` is not `https` with a domain name, contains credentials, a query or `#`, or belongs to a Pancake domain. | | `invalid_condition` | A `conditions` key does not apply to the target's resource. | --- Source: https://crm.pancake.in/developers/apps/targets.md # Extension points > Applies to `api_version: "2026-01"`. An extension point (target) is a place POS exposes for apps. Each target defines where it is shown, how it is presented, what context data the module receives and what it must return. ## Naming Targets attached to a POS record are named `.`, for example `order.details.action` or `purchase.index.selection-action`. Every resource offers the same six locations, so an app written for orders works the same way for purchases or warehouse transfers. | Resource | POS screen | List | Detail form | |---|---|---|---| | `order` | Orders | Order list | Order form | | `product` | Products | Product list | Product modal | | `purchase` | Purchases (goods receipts) | Purchase list | Purchase modal | | `export` | Exports (goods issues) | Export list | Export modal | | `warehouse_transfer` | Warehouse transfers | Transfer list | Transfer modal | ## Target list | Target | Location in POS | Presentation | |---|---|---| | `nav.item` | Main menu, "Apps" group | Declared only, no code | | `app.page` | The app's page `/shop//apps//` | iframe or Worker UI | | `.details.action` | Action menu of the detail form | headless, modal, fullscreen, navigate | | `.index.action` | Action menu on each list row | headless, modal, fullscreen, navigate | | `.index.selection-action` | Bulk action bar when several records are selected | headless, modal, fullscreen, navigate | | `.details.block` | A box inside the detail form | Worker UI | | `.before_submit` | Before a record is created/updated on the detail form | hook, headless | | `.after_save` | After a record is created/updated on the detail form | hook, headless | `` is one of `order`, `product`, `purchase`, `export`, `warehouse_transfer`. Planned: `order.print-action` (custom print templates), `order.index.column` (custom columns in the order list), `customer.details.block`. ## Module convention Every module is an ES module with a function as its `export default`. The function receives the context data (same as `pos.data`) and may be `async`. ```ts // Extensions with UI (block, modal/fullscreen action, Worker UI page): render into document.body export default async function (data) { render(, document.body); } // Headless extensions and hooks: return the result expected by the target export default async function (data) { return { message: 'Done' }; } ``` ## Shared types Context data has the same shape for every resource: `data.resource` names the resource, `data.record` is the saved record (`data.records` in the bulk action bar) and `data.draft` is what the detail form currently shows. ```ts type Resource = 'order' | 'product' | 'purchase' | 'export' | 'warehouse_transfer'; type RefOf = { order: OrderRef; product: ProductRef; purchase: PurchaseRef; export: ExportRef; warehouse_transfer: WarehouseTransferRef; }[R]; type DraftOf = { order: OrderDraft; product: ProductDraft; purchase: PurchaseDraft; export: ExportDraft; warehouse_transfer: WarehouseTransferDraft; }[R]; // Detail form: *.details.action, *.details.block, *.before_submit type DetailsData = { resource: R; mode: 'create' | 'edit'; record: RefOf | null; // null while the record is unsaved draft: DraftOf; // includes unsaved changes }; // One list row: *.index.action type IndexData = { resource: R; record: RefOf; }; // Bulk action bar: *.index.selection-action type SelectionData = { resource: R; records: RefOf[]; // up to 500 records }; ``` Example `pos.data` in `purchase.details.action`: ```json { "resource": "purchase", "mode": "edit", "record": { "id": "7c1d…", "display_id": 318, "custom_id": null, "status": 0, "warehouse_id": "a52f…", "updated_at": "2026-10-06T02:45:00" }, "draft": { "status": 0, "warehouse_id": "a52f…", "supplier_products_id": "e09b…", "tags": [], "note": null, "items": [{ "variation_id": "41aa…", "quantity": 20, "imported_price": 1850000 }], "total_price": 37000000 } } ``` ### Record id | Resource | Id used with the Open API | Query added by `navigate` | |---|---|---| | `order` | `record.system_id` | `order_id`, `order_ids` | | `product` | `record.id` | `product_id`, `product_ids` | | `purchase` | `record.id` | `purchase_id`, `purchase_ids` | | `export` | `record.id` | `export_id`, `export_ids` | | `warehouse_transfer` | `record.id` | `warehouse_transfer_id`, `warehouse_transfer_ids` | ### Types per resource ```ts type OrderRef = { id: string | number; // displayed order id (the custom id if the shop uses one, otherwise the system id) system_id: number; // system order id, as the order's system_id field in the Open API: /shops/{shop_id}/orders/{system_id} status: number; updated_at: string; // ISO 8601 }; type OrderDraft = { status: number; customer_id: string | null; bill_full_name: string | null; bill_phone_number: string | null; warehouse_id: string | null; tags: (number | string)[]; note: string | null; items: { variation_id: string; product_id: string; quantity: number; price: number; // unit price after line discount }[]; total_price: number; }; type ProductRef = { id: string; // UUID: /shops/{shop_id}/products/{id} display_id: number; custom_id: string | null; // product code name: string; is_hide: boolean; updated_at: string; }; type ProductDraft = { name: string; custom_id: string | null; category_ids: number[]; brand_id: number | null; tags: (number | string)[]; variations: { id: string | null; // null for a variation not saved yet custom_id: string | null; barcode: string | null; retail_price: number; }[]; }; type PurchaseRef = { id: string; // UUID: /shops/{shop_id}/purchases/{id} display_id: number; custom_id: string | null; status: number; warehouse_id: string; updated_at: string; }; type PurchaseDraft = { status: number; warehouse_id: string | null; supplier_products_id: string | null; // supplier tags: (number | string)[]; note: string | null; items: { variation_id: string; quantity: number; imported_price: number; }[]; total_price: number; }; type ExportRef = { id: string; // UUID: /shops/{shop_id}/export/{id} display_id: number; custom_id: string | null; status: number; type: number; // export type as in the Open API (normal, defective goods…) warehouse_id: string; updated_at: string; }; type ExportDraft = { status: number; type: number; warehouse_id: string | null; tags: (number | string)[]; note: string | null; export_items: { variation_id: string; quantity: number; }[]; }; type WarehouseTransferRef = { id: string; // UUID: /shops/{shop_id}/transfers/{id} display_id: number; custom_id: string | null; status: number; from_warehouse_id: string; to_warehouse_id: string; updated_status_at: string; }; type WarehouseTransferDraft = { status: number; from_warehouse_id: string | null; to_warehouse_id: string | null; note: string | null; items: { variation_id: string; quantity: number; }[]; }; ``` - Field names and status codes follow the Open API. Fields the user may not see (for example the imported price without the matching permission) are left out, and fields listed in `access.hidden_order_fields` are removed from `OrderDraft` (their value becomes `undefined`). - Context data only includes the minimum fields. Call the Open API with `pos.api` when you need more (see [Runtime API](/developers/apps/en/runtime-api)). - If a record does not satisfy the app's `access` limits (warehouse, product category, order source…), POS does not show the extension for that record. ## nav.item Adds an item to the main menu that opens an `app.page`. | Manifest | Description | |---|---| | `label` | Label in the menu | | `page` | `id` of the `app.page` extension | No module. The item is only shown to people who can see the app (every shop member by default, see [App lifecycle](/developers/apps/en/overview#app-lifecycle)). ## app.page The app's own page. | Manifest | Description | |---|---| | `title` | Page title | | `render` | `iframe`: free-form SPA; `worker`: Worker UI with `pos-*` elements | | `entry` | HTML file in the bundle (with `iframe`) | | `url` | Self-hosted page on your own server (with `iframe`, instead of `entry`) | | `module` | Module (with `worker`) | Context data: ```ts type PageData = { path: string; // the part after /apps/, for example "/returns" query: Record; // the POS URL query string }; ``` - **Iframe pages:** the URL is synced both ways; when the app changes its internal route the POS URL follows, so reloads, the back button and shared links all work. The page connects to POS through the SDK, see [Runtime API › Iframe pages](/developers/apps/en/runtime-api#iframe-pages). - **Worker UI pages:** the worker has no history. Change the path with `pos.navigate({ page, path, query })`; POS updates the URL and emits `data-change` with the new `path` and `query` without recreating the worker. The browser back button also emits `data-change`. ## `*.details.action` Adds an item to the action menu of a detail form. App items sit in an "Apps" group at the bottom of the menu, in declaration order. | Resource | Menu | |---|---| | `order` | The action menu opened from the Save button of the order form | | `purchase`, `export`, `warehouse_transfer` | The status menu in the footer of the detail modal | | `product` | An "Apps" button in the product modal header, shown only when an app adds actions there | Context data: `DetailsData`. For orders, app items are not shown when the menu is in print-only mode or actions are hidden. ### Action types | `presentation` | On click | Module returns | |---|---|---| | `headless` | Runs the module in the worker; the menu item shows a running state | `{ message?: string, refresh?: boolean }` or nothing | | `modal` | Opens a medium modal; the module renders its content | Nothing; call `pos.close()` to close | | `fullscreen` | Opens a full-screen modal; closing returns to the same screen | Same as `modal` | | `navigate` | Navigates to `page` at `path`, adding the record id to the query (see [Record id](#record-id)) | No module | For `headless`: - If it returns `message`, POS shows a success toast with that text. - If it throws (`throw new Error('...')`), POS shows an error toast with `error.message`. - `refresh` defaults to `true`: POS reloads the record when the module finishes. - Maximum run time is 30 seconds; after that the worker is stopped and an error is reported. The videos below use orders; other resources behave the same way. ### should_render The `should_render` module receives the same context data and returns a `boolean`. - POS evaluates it ahead of time when the record loads or changes, caching the result by record id and its update time (`updated_status_at` for warehouse transfers). - Timeout is 300 ms; on timeout or error the item is **hidden**. - Prefer `conditions` in the manifest; only use `should_render` for complex conditions. ## `*.index.action` Like `*.details.action`, but on each row of the list. | Resource | Menu | |---|---| | `order`, `purchase`, `export`, `warehouse_transfer` | The status menu on each row | | `product` | A "⋯" menu at the end of each row, shown only when an app adds actions there | Context data: `IndexData`. ## `*.index.selection-action` An item on the action bar shown when several records are selected in the list. Context data: `SelectionData`. With `navigate`, the query contains every id, for example `purchase_ids=,,...`. ## `*.details.block` A box inside the detail form. | Resource | Position | |---|---| | `order` | Right after the Note section | | `product`, `purchase`, `export`, `warehouse_transfer` | At the end of the detail modal, above the footer buttons | - Context data: `DetailsData` (same as `*.details.action`). - When the user edits the form, POS updates `pos.data` and emits a `data-change` event (see [Runtime API › Events](/developers/apps/en/runtime-api#events)). - The box is as wide as its column; its height grows with its content. - The box never blocks the form: POS shows a skeleton while the worker starts. ## `*.after_save` Runs after a record is successfully created or updated **on its detail form in POS web**. ```ts type AfterSaveInput = { resource: R; mode: 'create' | 'edit'; record: RefOf; }; ``` - Does not block the user; the return value is ignored. - Errors only go to the app's error log and are not shown to the user. ## `*.before_submit` Runs before a record is created or updated **on its detail form in POS web**. ```ts type BeforeSubmitInput = DetailsData; type BeforeSubmitResult = | { ok: true } | { ok: false; message: string }; // message up to 200 characters, shown to the user ``` - Hooks from several apps run in parallel. If any hook returns `ok: false`, the record is not saved; POS shows every `message`. - When `timeout_ms` is exceeded or the module fails, `on_error` decides (`allow` saves, `block` stops the save). - This version can only **block**; it cannot modify the record. ### When hooks run `*.before_submit` and `*.after_save` only run when the user saves on the detail form in POS web. They do **not** run for changes made elsewhere: the status menu on list rows, bulk updates, Excel import, the Open API, the mobile app or marketplace sync. Rules that must always hold belong on the server. --- Source: https://crm.pancake.in/developers/apps/components.md # UI components > Applies to `api_version: "2026-01"`. Worker UI extensions (blocks, `modal`/`fullscreen` actions, `render: "worker"` pages) build their UI only from the `pos-*` elements below. POS renders each element with its own components, so the UI always matches POS. Iframe pages do not use this list; they use free-form HTML/React and can add `@pancake-pos/ui` to look like POS. ## General rules - **Only use elements from this list.** Plain HTML tags (`div`, `span`, `img`, `table`, `script`…) and unknown elements are not rendered; the extension logs an error in the app's error log. - **Text** is only displayed inside `pos-text`, `pos-heading`, `pos-badge`, `pos-link`, `pos-button`, `pos-banner`. - **No** `style`, `className` or raw HTML. Colors, spacing and fonts follow POS. - **URLs** (`href`, `src`) must be `https://`. - **Events** pass values through `event.detail`. In JSX use `onClick`, `onChange`… as with regular components. In plain JS, the event name is the part after `on` in lowercase: `onPageChange` → `addEventListener('pagechange', …)`. - **Limits**: 2,000 elements per extension; `pos-table` renders at most 500 rows at a time (use pagination). - Array/object properties (`options`, `columns`, `rows`, `pagination`) are passed directly in JSX; no JSON.stringify needed. ```tsx setCode(e.detail)} /> Save ``` Without a framework, build the tree with the runtime's DOM API (see [Worker environment](/developers/apps/en/runtime-api#worker-environment)): ```js let code = ''; const field = document.createElement('pos-text-field'); field.label = 'Ticket code'; field.addEventListener('change', (e) => { code = e.detail; }); const button = document.createElement('pos-button'); button.variant = 'primary'; button.textContent = 'Save'; button.addEventListener('click', () => save(code)); const stack = document.createElement('pos-stack'); stack.gap = 'small'; stack.append(field, button); document.body.append(stack); ``` ## Layout ### pos-stack Stacks children vertically or horizontally. | Property | Type | Default | Description | |---|---|---|---| | `direction` | `'vertical' \| 'horizontal'` | `'vertical'` | Stacking direction | | `gap` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Space between children | | `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | `'stretch'` | Cross-axis alignment | | `justify` | `'start' \| 'center' \| 'end' \| 'space-between'` | `'start'` | Main-axis alignment | | `wrap` | `boolean` | `false` | Allow wrapping (with `horizontal`) | ### pos-grid A grid of equal columns; collapses to one column on narrow screens. | Property | Type | Default | Description | |---|---|---|---| | `columns` | `1–6` | `2` | Number of columns | | `gap` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Spacing | ### pos-section A titled container, typically the outer shell of a block. | Property | Type | Description | |---|---|---| | `heading` | `string` | Title | | `description` | `string` | Short text below the title | Slot `actions`: buttons shown at the right of the title. ```tsx Reload ... ``` ### pos-divider A horizontal rule. No properties. ## Display ### pos-text | Property | Type | Default | |---|---|---| | `tone` | `'default' \| 'subdued' \| 'success' \| 'warning' \| 'critical'` | `'default'` | | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | | `weight` | `'regular' \| 'semibold' \| 'bold'` | `'regular'` | ### pos-heading | Property | Type | Default | |---|---|---| | `level` | `1–4` | `3` | ### pos-badge A short status label. | Property | Type | Default | |---|---|---| | `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` | ### pos-money Displays an amount using the shop's currency and number format. | Property | Type | Description | |---|---|---| | `value` | `number` | Amount | | `currency` | `string` | ISO 4217 code; defaults to the shop currency | | `tone` | same as `pos-text` | Text color | ### pos-link | Property | Type | Description | |---|---|---| | `href` | `string` | `https://` URL, always opens in a new tab | `onClick` event: use it when the link performs an action in the app instead of opening a URL (omit `href`). ### pos-image | Property | Type | Default | Description | |---|---|---|---| | `src` | `string` | | An `https://` URL on a domain listed in `network.allowed_origins`, or `public/` for a file in the project's `public/` folder | | `alt` | `string` | | Alternative text | | `width`, `height` | `number` | | Size (px) | | `fit` | `'cover' \| 'contain'` | `'cover'` | How the image fits | POS loads images through a proxy on the sandbox: image types only, up to 5 MB. Images from undeclared domains are not shown (development mode logs a warning). ## Inputs Properties shared by every input: | Property | Type | Description | |---|---|---| | `label` | `string` | Label | | `value` | depends on the component | Current value (controlled component) | | `disabled` | `boolean` | Disable the input | | `required` | `boolean` | Show the required marker (does not validate) | | `error` | `string` | Error message below the input | | `help` | `string` | Hint below the input | Shared events: `onChange` (`event.detail` is the new value), `onBlur`. POS shows what the user types immediately and then sends `onChange` to the app, so inputs never lag while the app is busy. If the app sets a `value` different from what the user just entered (for example uppercasing it or clearing the field), POS shows the app's value. Set `value` directly in `onChange`; changing it after an `await` may overwrite what the user is typing. ### pos-text-field | Property | Type | Description | |---|---|---| | `value` | `string` | | | `placeholder` | `string` | | | `multiline` | `boolean \| number` | Multi-line input; a number sets the visible rows | | `maxLength` | `number` | Character limit | ### pos-number-field | Property | Type | Description | |---|---|---| | `value` | `number \| null` | | | `min`, `max`, `step` | `number` | | | `suffix` | `string` | Unit shown after the number, for example `"%"` | ### pos-select | Property | Type | Description | |---|---|---| | `options` | `{ label: string; value: string; disabled?: boolean }[]` | Choices | | `value` | `string \| string[]` | An array with `multiple` | | `multiple` | `boolean` | Multiple selection | | `placeholder` | `string` | | | `searchable` | `boolean` | Allow typing to filter | ### pos-checkbox | Property | Type | Description | |---|---|---| | `checked` | `boolean` | | | `label` | `string` | | `onChange`: `event.detail` is a `boolean`. ### pos-date-picker | Property | Type | Description | |---|---|---| | `value` | `string \| null` | Date as `YYYY-MM-DD` | | `min`, `max` | `string` | Selectable range, as `YYYY-MM-DD` | ## Feedback ### pos-banner A prominent message inside the extension. | Property | Type | Default | Description | |---|---|---|---| | `tone` | `'info' \| 'success' \| 'warning' \| 'critical'` | `'info'` | | | `heading` | `string` | | Title | | `dismissible` | `boolean` | `false` | Show a close button | `onDismiss` event. ### pos-spinner | Property | Type | Default | |---|---|---| | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | ## Data and actions ### pos-button | Property | Type | Default | Description | |---|---|---|---| | `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'danger'` | `'secondary'` | Button style | | `size` | `'small' \| 'medium'` | `'medium'` | | | `loading` | `boolean` | `false` | Shows a busy state and blocks clicks | | `disabled` | `boolean` | `false` | | `onClick` event. ### pos-table A data table. Cells only hold values (strings, numbers); POS formats them according to the column `format`. | Property | Type | Default | Description | |---|---|---|---| | `columns` | `Column[]` | | Column definitions | | `rows` | `object[]` | `[]` | Data, up to 500 rows | | `rowKey` | `string` | `'id'` | Field used as the row key | | `loading` | `boolean` | `false` | | | `pagination` | `{ page: number; pageSize: number; total: number } \| null` | `null` | App-side pagination | | `emptyText` | `string` | | Content when there are no rows | ```ts type Column = { key: string; // field name in the row title: string; align?: 'left' | 'center' | 'right'; width?: number; // px format?: 'text' | 'number' | 'money' | 'date' | 'datetime'; }; ``` Events: - `onPageChange`: `event.detail` is `{ page, pageSize }`. - `onRowClick`: `event.detail` is the clicked row. ```tsx setPage(e.detail.page)} /> ``` ### pos-modal A dialog opened from inside an extension (for example from a button in a block). | Property | Type | Description | |---|---|---| | `open` | `boolean` | Open state | | `heading` | `string` | Title | Slot `footer` for the dialog buttons. The `onClose` event fires when the user closes it or clicks outside. ```tsx setOpen(false)}> Push this order to the ERP? setOpen(false)}>Cancel Push ``` Actions with `presentation: "modal"` already get a POS modal; do not wrap them in `pos-modal`. ## Planned additions `pos-page`, `pos-tabs`, `pos-filter-bar`, `pos-chart` (POS draws the chart, the app only passes data), for full-screen Worker UI pages. --- Source: https://crm.pancake.in/developers/apps/runtime-api.md # Runtime API (pos) > Applies to `api_version: "2026-01"`. Every extension gets a `pos` object to read context, call the Open API and send commands to the POS UI. - **Extensions in the worker** (blocks, actions, hooks, Worker UI pages): `pos` is a global preloaded by the POS runtime before the module runs. - **Iframe pages**: call `connect()` from the SDK to get `pos` (see [Iframe pages](#iframe-pages)). Full types ship in `@pancake-pos/ui-extensions` (see [SDK & CLI](/developers/apps/en/sdk)). ## Overview ```ts interface Pos { apiVersion: string; // for example "2026-01" target: string; // the running extension's target extensionId: string; // the id declared in the manifest app: { id: string; handle: string; version: string }; shop: { id: number; name: string; currency: string; country: string; timezone: string }; user: { id: string; name: string; locale: string }; data: unknown; // context data, schema depends on the target api: PosApi; // Open API calls session: { getToken(): Promise<{ token: string; expiresAt: string }> }; toast: { show(message: string, options?: ToastOptions): void }; ui: { confirm(options: ConfirmOptions): Promise }; navigate(to: NavigateTarget): void; refresh(): void; close(): void; on(event: PosEvent, handler: (payload: any) => void): () => void; } ``` ### Availability | API | Block | Headless action | Modal/fullscreen action | Hook, should_render | Page | |---|---|---|---|---|---| | `shop`, `user`, `app`, `data` | ✓ | ✓ | ✓ | ✓ | ✓ | | `api`, `session` | ✓ | ✓ | ✓ | ✓ | ✓ | | `toast`, `ui.confirm` | ✓ | ✓ | ✓ | | ✓ | | `navigate` | ✓ | ✓ | ✓ | | ✓ | | `refresh` | ✓ | ✓ | ✓ | | | | `close` | | | ✓ | | | | `on('data-change')` | ✓ | | ✓ | | ✓ | | `page.*` | | | | | ✓ (iframe) | Calling an API outside its availability throws `PosRuntimeError` with `code: 'unavailable'`. ## pos.data The context data of the current target, also passed to the `export default` function. See each target's schema in [Extension points](/developers/apps/en/targets). - Targets on POS records share one shape: `pos.data.resource` names the resource (`order`, `purchase`…), `pos.data.record` is the saved record (`records` in the bulk action bar) and `pos.data.draft` is the detail form content. - Fields hidden by `access.hidden_order_fields` are not present in `pos.data`. - For blocks, `pos.data` updates as the user edits the form; listen to `data-change` to re-render. ## pos.api Calls the Open API with the app token. The SDK attaches the token, renews it and retries once when it expires. ```ts interface PosApi { baseUrl: string; // Open API root, for example "https://pos.pages.fm/api/v1" request(options: { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string; // relative to baseUrl, starts with /shops/{shop_id} query?: Record; body?: unknown; // sent as JSON signal?: AbortSignal; }): Promise; get(path: string, options?: { query?: Record; signal?: AbortSignal }): Promise; post(path: string, body?: unknown, options?: { query?: Record; signal?: AbortSignal }): Promise; put(path: string, body?: unknown, options?: { query?: Record; signal?: AbortSignal }): Promise; delete(path: string, options?: { query?: Record; signal?: AbortSignal }): Promise; } ``` - `path` must belong to the current shop (`/shops/${pos.shop.id}/...`). Calls to other shops are rejected. - The result is the raw Open API JSON (usually `{ success, data, ... }`). - The method must be listed in the app's `access.methods`; otherwise an error is thrown before the request is sent. ### Errors ```ts class PosApiError extends Error { status: number; // HTTP status code?: number | string; // Open API error_code when present body?: unknown; // response body } ``` The SDK throws `PosApiError` when the HTTP status is ≥ 400 **or** the body has `success: false`. See the error codes in [Data access › Error codes](/developers/apps/en/data-access#error-codes). ```ts try { const { record } = pos.data; // order.details.action: record is an OrderRef const res = await pos.api.get(`/shops/${pos.shop.id}/orders/${record.system_id}`); render(, document.body); } catch (error) { if (error.status === 403) pos.toast.show('You do not have permission to view this order', { tone: 'error' }); else throw error; } ``` ## pos.session.getToken Gets the current app token to call the Open API with another library. ```ts const { token, expiresAt } = await pos.session.getToken(); await fetch(`${pos.api.baseUrl}/shops/${pos.shop.id}/orders`, { headers: { Authorization: `Bearer ${token}` }, }); ``` Do not store the token long term or send it to external systems, except to the backend of a [self-hosted page](#self-hosted-pages). Tokens expire after 15 minutes; call `getToken()` again for a new one. ## pos.toast.show ```ts type ToastOptions = { tone?: 'success' | 'error' | 'info' | 'warning'; // default 'success' duration?: number; // seconds, default 3, from 1 to 10 }; ``` At most 3 toasts per 10 seconds per extension; extra toasts are dropped. ## pos.ui.confirm A confirmation dialog drawn by POS over the whole screen. ```ts type ConfirmOptions = { title: string; content?: string; okText?: string; // defaults to the POS "OK" label cancelText?: string; // defaults to the POS "Cancel" label danger?: boolean; // red confirm button }; if (await pos.ui.confirm({ title: 'Push this order to the ERP?' })) { // ... } ``` ## pos.navigate Navigates to one of the app's pages. ```ts type NavigateTarget = { page: string; // id of an app.page extension path?: string; // for example "/returns" query?: Record; }; pos.navigate({ page: 'home', path: '/returns', query: { order_id: String(pos.data.record.system_id) } }); ``` When called from a modal, the modal closes before navigating. If that page is already open, POS only updates the URL and emits `data-change` with the new `path` and `query`; the page is not reloaded. ## pos.refresh Asks POS to reload the data of the current screen (for example the open order or purchase) after the app updated it through the Open API. - POS reloads the list on screen and the open record. Unsaved changes in that record's form are replaced by the new data. - Repeated calls are merged: each resource reloads at most once every 3 seconds. ## pos.close Closes the open modal/fullscreen action. ## Events ```ts const off = pos.on('data-change', (data) => { // data has the same schema as pos.data }); off(); // unsubscribe ``` | Event | Fires when | Payload | |---|---|---| | `data-change` | Context data changes, for example the user edits a detail form (batched over 300 ms) or a page's path changes | the new `pos.data` | | `primary-action` | The user clicks the iframe page's primary button | none | ## Iframe pages A `render: "iframe"` page is a regular web app served from the root of the app's sandbox domain. Every path that is not a static file returns `index.html`, so routers use `/` as their `basename`. Paths starting with `/_pos/` are reserved for POS: do not use them as routes or file paths. ```ts import { connect } from '@pancake-pos/ui-extensions/page'; const pos = await connect(); // waits for POS to send context and token // pos.data: { path, query } ``` On top of the APIs above, iframe pages get: ```ts interface PosPage { setTitle(title: string): void; setPrimaryAction(action: { label: string; disabled?: boolean; loading?: boolean } | null): void; } pos.page.setTitle('Reconciliation for May 12'); pos.page.setPrimaryAction({ label: 'Export Excel' }); pos.on('primary-action', exportExcel); ``` - **URL sync:** the SDK watches `history.pushState`, `replaceState` and `popstate` inside the iframe and updates the POS URL. When a shared link is opened, the iframe loads the matching path. - **Height:** the iframe fills the POS content area; the page scrolls internally. - **Look and feel:** `@pancake-pos/ui` (POS theme and components) is recommended. - **Storage:** the iframe page's `localStorage` belongs to the app's sandbox domain only and is not shared with POS or other apps. ### Self-hosted pages Declare `url` instead of `entry` when the page already runs on your own server, such as an existing ERP web app. POS embeds `url` directly in the iframe; the bundle does not need a `page/` folder. ```json { "target": "app.page", "id": "home", "title": "ERP", "render": "iframe", "url": "https://erp.example.com/pos" } ``` - **Connecting:** install `@pancake-pos/ui-extensions` and call `connect()` as a bundled page does; `pos.page`, `pos.api`, `pos.toast` and the rest work the same way. - **Paths:** the path part of `url` is the app root. `/shop//apps//orders/5` in POS maps to `https://erp.example.com/pos/orders/5`. When the page leaves that root (for example `/login`), the POS URL stays as it is. `pos.data.path` is the page's real path. - **Allowing POS to embed it:** send `Content-Security-Policy: frame-ancestors https://pos.pancake.vn https://pos.pages.fm` so only POS can embed the page. Do not rely on the page's own sign-in cookies: browsers may block cookies in cross-site iframes (Safari does by default). - **Calling the API from your server:** send the app token (`pos.session.getToken()`) with requests to your backend, and have the backend call the Open API with it (`Authorization: Bearer`). The token lasts 15 minutes and carries only the app's access; do not store it long term. - **Review and changes:** admins see `url` when they activate a version. Your server decides what the page shows and can change it at any time without a new version, so only use servers you control. - **Trying it out:** `pos-app dev --page-url http://localhost:3000/pos` replaces `url` with a server on your machine; Vite or Next hot reload keeps working inside the iframe. ## Worker environment Extensions in the worker can only rely on: - Standard JavaScript syntax and built-ins (ES2020). - `fetch`, `Headers`, `Request`, `Response`, `AbortController`, `URL`, `URLSearchParams`. - `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`. - `console` (only visible in development mode). - The minimal DOM provided by the POS runtime so Preact or plain JS can build `pos-*` elements: `document.createElement`, `document.createTextNode`, `append`, `appendChild`, `insertBefore`, `removeChild`, `remove`, `setAttribute`, `removeAttribute`, `textContent`, `addEventListener`, `removeEventListener`, `parentNode`, `childNodes`, `firstChild`, `nextSibling`, `CustomEvent`. `document.body` is the root of the extension's UI. **Do not** use `window`, `document.querySelector`, `localStorage`, `sessionStorage`, `canvas`, DOM measurement (`getBoundingClientRect`…), `alert`/`confirm`/`prompt`, or load scripts from CDNs. These may not exist or may be locked down at any time. ## Error log - Development mode: errors and `console.*` show up in the browser DevTools. - Active version: uncaught errors, render errors and timeouts are recorded in the app's error log, visible in the [error log](/developers/apps/en/manage-apps#error-log) on the custom apps page. --- Source: https://crm.pancake.in/developers/apps/data-access.md # Data access Apps read/write shop data through **the existing Open API** ([Open API docs](/developers/api/en)): same endpoints, response format, pagination and update rules. Code that already works with an API key only needs a different way to get credentials. ```ts // Before: a static, full-access key that leaks easily when it sits in frontend code fetch(`https://pos.pages.fm/api/v1/shops/${SHOP_ID}/orders?api_key=${STATIC_KEY}`); // Inside an app: the SDK attaches the app token await pos.api.get(`/shops/${pos.shop.id}/orders`); ``` ## App token POS issues an app token automatically while an extension runs; apps do not need, and should not have, a static API key. | | Shop API Key | User API Key | **App token** | |---|---|---|---| | Issued by | Admin, long-lived | User, long-lived | POS, automatically, expires after 15 minutes | | Identity | The shop's connection account | A real user | A real user + the app | | Permissions | Full access (can be limited) | The user's permissions | **The user's permissions ∩ the app's `access`** | | Shop scope | One shop | Every shop the user belongs to | Only the shop currently open | | Used from | Servers | Servers, scripts | Inside the app in POS | - The token is bound to one shop, one user, one app and the running version. - The SDK sends the token in the `Authorization: Bearer ` header. `?api_key=` is still accepted for compatibility with existing Open API code, but not recommended because URLs easily end up in logs. - The token is revoked immediately when the app is disabled or removed, the admin changes `access`, or the user logs out or their session changes. Removing the user from the shop or changing their role takes effect on the next request. ## Effective permissions ```text App token permissions = permissions of the current user ∩ the app's access (as approved by the admin) ``` **User permissions** apply exactly as when the user works in POS: role, per-warehouse permissions, CRM table/field permissions, department, working hours, allowed IPs, enabled modules and subscription. **The app's `access`** narrows them further: allowed methods (only `GET` by default), warehouses, order sources, order statuses, order creation date, product categories/brands, hidden order fields. See [Manifest › access](/developers/apps/en/manifest#access). Consequences: - The same app shows more data to users with broader permissions than to users with narrower ones. - A request that works with a Shop API Key may be rejected with an app token. That is the intended behavior. - Apps should handle `403` instead of assuming every user has every permission. ### Always-blocked groups These groups are only available from a logged-in session in the POS UI; every kind of API key and app token is rejected: - Staff management: adding/editing staff, assigning roles. - Linking Google accounts. - API key management. - App management and app token issuance. ## Allowed endpoints - Only use endpoints listed in the [Open API docs](/developers/api/en). Internal endpoints used by the POS UI can change at any time and will be blocked for app tokens. - Every path must belong to the current shop: `/shops/{shop_id}/...`. - If data or an operation you need is missing, request it in the Open API instead of using internal endpoints. Main endpoints for the resources that have extension points: | Resource | Open API | Record id in the path | |---|---|---| | `order` | `GET`/`POST /shops/{shop_id}/orders`, `GET`/`PUT /shops/{shop_id}/orders/{id}` | `record.system_id` | | `product` | `GET /shops/{shop_id}/products/variations`, `POST /shops/{shop_id}/products`, `GET`/`PUT /shops/{shop_id}/products/{id}` | `record.id` | | `purchase` | `GET`/`POST /shops/{shop_id}/purchases`, `GET`/`PUT /shops/{shop_id}/purchases/{id}` | `record.id` | | `export` | `GET`/`POST /shops/{shop_id}/export`, `GET`/`PUT /shops/{shop_id}/export/{id}` | `record.id` | | `warehouse_transfer` | `GET /shops/{shop_id}/transfers`, `POST /shops/{shop_id}/transfers/multi`, `GET`/`PUT /shops/{shop_id}/transfers/{id}` | `record.id` | ## Context data `pos.data` holds the minimum data POS is already showing (for example the id and status of the open order or purchase), so extensions do not have to call the API right away. It is already filtered by the app's `access`. Call the Open API for anything else. ## Rate limits - Counted per **app + user + shop + endpoint**; all extensions of the same app share the quota: under 30 reads (`GET`) and under 100 writes per second. - When exceeded: HTTP `429`. Retry later with an increasing delay (for example 1 s, 2 s, 4 s), at most 3 times. - Do not call the API on every render. Batch requests, paginate, and use `AbortSignal` to cancel stale requests when the user changes filters. ## Audit Every write made with an app token is recorded with the **real user**, the **app** and the **version**. Automatic fields such as creator and last editor are set to the current user. ## Error codes The `code` column is the `error_code` field of the response body, which the SDK puts in `PosApiError.code`. | HTTP | `code` | Meaning | SDK behavior | |---|---|---|---| | 401 | `111` | The token expired or was revoked: the app was disabled, another version was activated, the departments that can see the app changed, or the user's login session changed (see [App token](#app-token)) | Gets a new token and retries once. If it still fails, throws `PosApiError` (`status: 401`, `code: 111`); if POS refuses to issue a new token (the app is disabled or the user can no longer see it), throws `PosRuntimeError` with `code: 'failed'` | | 403 | | Not allowed: role, warehouse, working hours, IP, always-blocked groups | Throws `PosApiError` | | 403 | `110` | The endpoint is not in the [Open API](/developers/api/en) | Throws `PosApiError` | | 404 | | Not found, or the data is outside the app's `access` | Throws `PosApiError` | | 404 | | The method is not in `access.methods` (when sent directly, bypassing the SDK) | The SDK blocks it before sending | | 429 | `106` | Rate limit exceeded | Throws `PosApiError`; the app retries | | 200 | depends on the endpoint | The body has `success: false`: a business error, see `message` | Throws `PosApiError` with `status: 200` | ## Calling external systems - Declare origins in `network.allowed_origins`; requests to other origins are blocked. - External servers must enable CORS for the app's sandbox origin, which is the `Origin` header of the requests the app sends while running the active version. In development mode the origin is the address you entered when turning it on (for example `http://localhost:5174`); enable CORS for it too if you want to test. - **Never send the app token elsewhere**, except to the backend of a [self-hosted page](/developers/apps/en/runtime-api#self-hosted-pages). If an external system needs POS data, the app reads it through the Open API and sends only what is needed. - **Never put secrets in app code.** Anyone who can open the app can download the bundle. To call systems that need a secret, put a backend of your own in between and authenticate callers separately. ## Background tasks Apps only run while someone has POS open. Scheduled jobs, syncs or event handling while nobody uses POS need the shop's own backend: - Call the Open API with a **User API Key** (recommended, user-scoped permissions) or a **Shop API Key** as today. - Receive change events through the shop's webhooks (Settings › Webhook - API). Planned for phase 2: a **per-app service key** (acting as a designated user, with the app's `access` applied) and **per-app webhook subscriptions** with HMAC signatures. ## App-owned data This version has no dedicated storage for apps. Options: - The shop's custom CRM tables, read/written through the Open API, with table- and field-level permissions built in. - The shop's own backend. - `localStorage` in an iframe page: stored only in the current browser, suitable for UI preferences. --- Source: https://crm.pancake.in/developers/apps/sdk.md # SDK & CLI ## Packages | Package | Used in | Role | |---|---|---| | `@pancake-pos/ui-extensions` | `extensions/`, `page/` | Types for `pos` and `pos-*` elements, the `defineExtension` helper, the client for iframe pages (`/page`) | | `@pancake-pos/ui` | `page/` | Theme and POS-styled components for iframe pages | | `@pancake-pos/app-cli` | Dev machine, CI | The `pos-app` command: sign in, try in POS, check, build, upload | | `@pancake-pos/eslint-plugin` | Editor, `pos-app check` | Code rules for apps; editors show problems as you type | | `create-pos-app` | Once | Creates a starter project with the packages above and an `AGENTS.md` | Package versions follow `api_version`: package `2026.1.x` is for `"api_version": "2026-01"`. The CLI reports an error when they do not match. ## SDK and the POS runtime Code running in the sandbox has two parts, provided by two different parties: ```text Worker inside the app sandbox ┌─────────────────────────────────────────────────────┐ │ App bundle (built and uploaded by the shop) │ │ extension modules + Preact or plain JS │ ├─────────────────────────────────────────────────────┤ │ POS runtime (provided by POS, not in the bundle) │ │ minimal DOM · pos-* elements · global pos object │ │ postMessage channel · app token · Open API calls │ └─────────────────────────────────────────────────────┘ ``` - **The POS runtime** is loaded into the worker before the app's modules. Because it is not part of the bundle, POS can ship security fixes or improvements without apps redeploying, and app modules stay small. - **`@pancake-pos/ui-extensions`** mostly provides **types** and helpers in `extensions/`; it adds almost no code to the bundle. - **Iframe pages** have no preloaded runtime: the app imports `connect()` from `@pancake-pos/ui-extensions/page`, and this small client is bundled with the page. ### What the SDK and runtime handle for you | Concern | Details | |---|---| | Messaging | postMessage between the sandbox and POS, sender verification, commands and events in both directions | | Authentication | Fetches the app token, renews it before it expires, refreshes and retries once on 401 | | Open API calls | `pos.api.*`, adds the `Authorization` header, blocks methods outside `access.methods`, turns errors into `PosApiError` | | Worker UI | Minimal DOM, `pos-*` element definitions, batching changes sent to POS, delivering POS events to the worker | | Context | Updates `pos.data`, emits `data-change` events | | Iframe pages | Syncs the URL with POS, `setTitle`, `setPrimaryAction` | | Development mode | Loads the bundle from `localhost`, reloads extensions and pages after every build | ## Installation Projects created with `create-pos-app` already have everything. To add the SDK to an existing project: ```bash npm install @pancake-pos/ui-extensions preact npm install -D @pancake-pos/app-cli typescript eslint @pancake-pos/eslint-plugin npm install @pancake-pos/ui react react-dom # only if you have an iframe page ``` ## TypeScript The `tsconfig.json` at the project root covers `extensions/`: ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "jsxImportSource": "preact", "strict": true, "types": ["@pancake-pos/ui-extensions"] }, "include": ["extensions"] } ``` - `types` declares the global `pos` object and the `pos-*` elements for JSX (properties, events, `event.detail`). - The `page/` folder has its own `page/tsconfig.json` with `"jsxImportSource": "react"`, generated by the template; do not merge it into the root tsconfig. - `extensions/` supports **Preact** (recommended, small) and **plain JS** using the runtime's DOM API. **React is not supported** in `extensions/` yet: React 18 turns array/object properties into strings and cannot listen to `pos-*` element events; React 19 is under evaluation. Iframe pages can use React as usual. ## defineExtension An optional helper that makes the type of `data` and of the return value match the target. ```ts import { defineExtension } from '@pancake-pos/ui-extensions'; export default defineExtension('order.before_submit', async (data) => { // data: DetailsData<'order'>; must return BeforeSubmitResult if (data.draft.total_price > 20_000_000 && !data.draft.bill_phone_number) { return { ok: false, message: 'Large orders need a phone number.' }; } return { ok: true }; }); ``` `defineExtension` returns the function you pass in, so it is equivalent to `export default async function (data)`. The resource comes from the target name, so the same helper types `data.draft` as a `PurchaseDraft` in `purchase.before_submit` or `data.records` as `WarehouseTransferRef[]` in `warehouse_transfer.index.selection-action`. Exported types: `Resource`, `DetailsData`, `IndexData`, `SelectionData`, `AfterSaveInput`, `BeforeSubmitResult`, `HeadlessActionResult`, `PageData`, the `Ref`/`Draft` pair of each resource (`OrderRef`, `OrderDraft`, `ProductRef`, `ProductDraft`, `PurchaseRef`, `PurchaseDraft`, `ExportRef`, `ExportDraft`, `WarehouseTransferRef`, `WarehouseTransferDraft`), `PosApiError`, `PosRuntimeError`. ## Iframe pages ```ts import { connect } from '@pancake-pos/ui-extensions/page'; const pos = await connect({ timeout: 10_000 }); ``` - `connect()` waits for POS to send the context and token; after `timeout` (10 seconds by default) it throws, usually because the page was opened outside POS. - The page runs only inside a POS iframe. Opening a sandbox URL in its own tab or window, including a popup the page opens itself, is refused (`403`). A popup sign-in flow must return to a page of yours outside the sandbox and report back to the app through your server. - The page's `pos` has the same API as in the worker, plus `pos.page.*`. See [Runtime API](/developers/apps/en/runtime-api#iframe-pages). ## CLI (pos-app) | Command | Description | |---|---| | `pos-app login --token [--shop ]` | Stores the CLI session on this machine. Copy the full command, token included, from the **Connect CLI** button on the custom apps management page (requires the Settings permission); the token only works for that shop and the CLI's own commands (upload, versions, errors, sample data; it cannot activate apps or sign in to POS), lasts 30 days and stops working when you sign out of POS or when it is revoked from the **Connect CLI** button | | `pos-app logout` | Revokes the CLI token, then removes the session from this machine | | `pos-app dev [--port 5174] [--page-url ]` | Watch and build, serve at `http://localhost:` for POS development mode; POS reloads after every build. `--page-url`: a [self-hosted page](/developers/apps/en/runtime-api#self-hosted-pages) running on your machine (for example `http://localhost:3000`) | | `pos-app check` | Validates the manifest and code (see below) without uploading | | `pos-app build` | Builds into `dist/` | | `pos-app deploy [--shop ] [--message "..."]` | Runs `check` and `build`, uploads a new (inactive) version | | `pos-app versions [--shop ]` | Lists uploaded versions and the active one | | `pos-app mcp [--shop ]` | Starts the MCP server for AI tools, see [MCP](#mcp) | Every command accepts `--json` for machine-readable output (CI, scripts, the MCP server). The `pos-app` command is available once `@pancake-pos/app-cli` is installed, so `npx pos-app …` works inside the project folder. Outside a project, call it by package name: `npx @pancake-pos/app-cli `. The `pos-app` package on npm is not published by Pancake. Activation and rollback happen on the [custom apps page](/developers/apps/en/manage-apps) in POS, so an admin always approves permissions before an app runs. ### pos-app check Static checks that run automatically before `deploy`. Especially useful for AI-generated code: - **Manifest:** valid against the [schema](/developers/apps/en/manifest), reported with line numbers; targets exist in `api_version`; `module`/`entry` files exist; `conditions` keys are valid for the resource. - **Code:** runs the [code rules](#code-rules) with a built-in configuration; nothing to configure in the project. - **Sizes** of modules and bundles are within limits. `error` findings block `deploy`; `warning` findings are only reported. Each finding has a file, line, rule id and a suggested fix (`--json` for CI and MCP). `pos-app check` ignores `eslint-disable` comments, so disabling a rule in code does not bypass this check. ### Code rules The rules ship in `@pancake-pos/eslint-plugin`. Element rules are generated from the component catalog of the exact `api_version`, so they always match POS. | Group | Rules | Level | |---|---|---| | Worker environment | No `window`, `document.querySelector`, `localStorage`, `alert`…; no `react`, `react-dom`, CSS files or UI libraries imported in `extensions/` | error | | `pos-*` elements | Elements, properties and events exist in the [catalog](/developers/apps/en/components) (with a suggestion for typos); valid enum values; valid children and slots; text only inside elements that accept text | error | | Data and security | `pos.api.*` methods are listed in `access.methods`; literal `fetch` URLs and `pos-image` sources belong to `network.allowed_origins` (images may also use `public/`); no hardcoded shop id; no strings that look like API keys, tokens or secrets | error | | Behavior | Modules `export default` a function; rendered lists have a `key`; input `value` is not set after an `await`; `before_submit` does not chain several API calls | warning | | Iframe pages | No `window.top` or `window.parent`; no routes starting with `/_pos/` | error | Static checks cannot catch dynamic cases such as element names built from variables. In development mode POS also logs warnings to the console at run time. ### Editor feedback Projects created with `create-pos-app` include an `eslint.config.js`: ```js import posApp from '@pancake-pos/eslint-plugin'; // recommended reads pos-app.json for api_version, access and allowed_origins export default [...posApp.configs.recommended]; ``` Install the ESLint extension in your editor (VS Code, Cursor…) to see problems as you type. `npx eslint .` gives the same results as the code part of `pos-app check`. ### Build output ```text dist/ ├─ pos-app.json # manifest with module paths pointing to built files ├─ ext/ │ ├─ order-block.js # one ES module per module, Preact included │ └─ push-erp.js ├─ public/ # images for pos-image, copied as is └─ page/ # iframe page built by Vite ├─ index.html └─ assets/... ``` - Extension modules are built with esbuild, targeting ES2020, without code splitting and without imports from CDNs. - Iframe pages are built with Vite using the configuration that ships with the template. ## MCP `pos-app mcp` starts an [MCP](https://modelcontextprotocol.io) server (stdio) so AI tools can look up the docs, see the shop's real metadata, check the app, try it, read errors, deploy and report bugs to Pancake without leaving the chat. ```bash npx @pancake-pos/app-cli mcp --shop 123 # run in the app's project folder, or add --cwd ``` How to add it to Claude Code, Cursor, VS Code, Codex and Claude Desktop, the tool list and sample prompts: [Build with AI › MCP](/developers/apps/en/ai-guide#mcp).