# 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://order.pancake.vn/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, `headless` action and `*.print-action` run time | 30 seconds | | `*.index.column` timeout | 5 seconds | | `*.print-action` HTML | 2 MB | | Files downloaded with `pos.download` | 10 MB per file | | 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 | Available: `before_submit` hook (block saves, set the note and tags), Worker UI pages (`render: "worker"`), app print templates (orders, purchases, exports, transfers), list columns, customer boxes, actions and columns. Not yet: per-app webhooks and service keys, app storage | | Phase 3 | Consider the mobile app, install one app across shops of the same owner | --- Source: https://order.pancake.vn/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 │ ├─ order-summary.ts │ └─ report.tsx # sample Worker UI page (Standard screens) ├─ 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 npm run 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 (`npm run 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 `npm run check` to validate the manifest as well. ## 4. Build and upload ```bash npm run 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://order.pancake.vn/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 `npm run 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://order.pancake.vn/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 and Open API endpoints, 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 | | `search_api` | `query`, for example `order list` or `post order` | Open API endpoints the app may call with `pos.api` (method, path, summary) and the `access.methods` each needs | | `get_api` | `path` and `method`, or `schema` | How to call one endpoint: the `pos.api` call, path and query parameters, request body and response shapes, an example. `field` expands a nested object (`data.items`, `body.customer`) | | `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. `search_api` and `get_api` read the Open API copy bundled with the CLI and call nothing. 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. `search_api` and `get_api` before each `pos.api` call, to use a published endpoint with its exact parameters and response shape. 4. Write the code, then `check_app` until it reports no errors. 5. `dev_start`, try the app in POS, then `get_errors` to fix runtime errors. 6. `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. - [ ] Worker UI screens follow [Standard screens](/developers/apps/en/components#standard-screens): no emoji, no repeated title, numbers in `pos-stat`. - [ ] `npm run check` reports no errors. - [ ] Tested with `npm run 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. - extensions/report.tsx: sample Worker UI page laid out like other POS screens; copy its structure for new pages. - page/: iframe page (React + Vite), gets `pos` from ``/`usePos()` in `@pancake-pos/ui` (or `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. - Worker UI screens follow Components > Standard screens: pos-page as the page frame, pos-section cards, pos-stat in pos-grid for numbers, one pos-filter-bar for filters, pos-tabs with badge for status filters. No emoji or decorative characters in labels, no repeated page title, no pos-section around every element. - 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, changes? } (changes: note, add_tags) or { ok: false, message }; *.print-action returns { html }; *.index.column returns { values }. - Catch errors on every API call; handle 403 and 429. ## Commands - Run the CLI through the npm scripts below, or as npx @pancake-pos/app-cli (login, versions, mcp). Never run npx pos-app: the pos-app package on npm is not Pancake's, and npx downloads it when the project is not installed. - npm run 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. - npm run typecheck: TypeScript for extensions/ (Preact) and page/ (React). - npm run check: validate the manifest and code; fix every error before deploying. - npm run deploy: build, validate the manifest, upload a new version (options after --, for example npm run deploy -- --message "..."). - With the pos-app MCP server: get_target/list_shop_meta/sample_context before writing code, search_api/get_api before every pos.api call, 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://order.pancake.vn/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://order.pancake.vn/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 page, see [app.page](/developers/apps/en/targets#app-page)). | | `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. | ### Customer block, print template and list column fields | Target | Field | Description | |---|---|---| | `customer.details.block` | `module` (required), `should_render` | As for blocks; no `conditions`. | | `customer.details.action`, `customer.index.action`, `customer.index.selection-action` | As for actions | No `conditions`. | | `customer.index.column` | As for `.index.column` | `values` is keyed by the customer's `id`. | | `.print-action` | `label` (required) | Template name in the "Print template" picker, up to 40 characters. | | `.print-action` | `module` (required) | Module returning `{ html, title? }`. | | `.index.column` | `title` (required) | Column title, up to 30 characters. | | `.index.column` | `module` (required) | Module returning `{ values }` keyed by record id (`system_id` for orders, `id` otherwise). | | `.index.column` | `format` | `text` (default), `number`, `money`, `date`, `datetime`. | | `.index.column` | `align` | `left`, `center`, `right`. | | `.index.column` | `width` | Column width in px, 60–400, default 140. | See [Extension points](/developers/apps/en/targets#customer) for the context data and return values. ### 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://order.pancake.vn/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`. Resources share the same location names, so an app written for orders works the same way for purchases or warehouse transfers. Some locations do not exist for every resource: products have no print templates (`print-action`), and customers only have boxes, actions and columns (see [`customer.*`](#customer)). | 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 | | `customer` | Customers | Customer list | Customer profile | ## 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 | | `.index.column` | A column in the list | hook, headless | | `.print-action` | A print template in the "Print template" picker (except `product`) | headless, returns HTML | | `customer.` | Customer profile and list: `details.block`, `details.action`, `index.action`, `index.selection-action`, `index.column` | See [`customer.*`](#customer) | `` is one of `order`, `product`, `purchase`, `export`, `warehouse_transfer`. Products have no `print-action` because they only print barcodes. ## 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` | | `customer` | `record.id` | `customer_id`, `customer_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`. - Build Worker UI pages with `pos-page`, `pos-filter-bar`, `pos-stat`, `pos-chart`, `pos-tabs`, `pos-table` and the other elements following [Standard screens](/developers/apps/en/components#standard-screens) (see [Components](/developers/apps/en/components#worker-ui-pages)). Set the title and primary button on the POS title bar with `pos.page`, as on iframe pages (see [Runtime API › Worker UI pages](/developers/apps/en/runtime-api#worker-ui-pages)). ```tsx import { render } from 'preact'; import { useEffect, useState } from 'preact/hooks'; function Report() { const [tab, setTab] = useState(pos.data.query.tab || 'day'); useEffect(() => pos.on('data-change', (data) => setTab(data.query.tab || 'day')), []); return ( pos.navigate({ page: 'report', query: { tab: e.detail } })}> ); } export default function () { pos.page?.setTitle('Revenue report'); render(, document.body); } ``` ## `*.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; changes?: BeforeSubmitChanges } | { ok: false; message: string }; // message up to 200 characters, shown to the user type BeforeSubmitChanges = { note?: string; // replaces the note, up to 2,000 characters add_tags?: (number | string)[]; // adds tags by the shop's tag ids, up to 20 }; ``` - 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). - `changes` can only set the note and add tags. POS applies them to the form before saving and tells the user which app changed the record. | Resource | `note` | `add_tags` | |---|---|---| | `order` | Order note | Order tags, following the shop's tag groups as when picked by hand | | `purchase`, `export` | Note | Tags | | `warehouse_transfer` | Note | Not available | | `product` | Not available | Product tags | - Fields the resource does not have are ignored. Tag ids the shop does not have, or that are already set, are ignored. Get tag ids from the Open API or the MCP `list_shop_meta` tool. - If two apps return different `note` values, POS stops the save and reports the conflict. Tags from several apps are combined. - An invalid `changes` value is handled like a failing module (by `on_error`). ```ts export default defineExtension('order.before_submit', async (data) => { if (data.draft.total_price < 20_000_000) return { ok: true }; return { ok: true, changes: { add_tags: [12], note: `${data.draft.note ?? ''}\nLarge order: check before shipping`.trim() } }; }); ``` ### 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. ## `customer.*` Customers have boxes, actions and columns like the other resources, but **no** `before_submit`, `after_save` (the profile saves each field as soon as it changes; there is no shared Save button) and no print templates. | Target | Location | Context data | |---|---|---| | `customer.details.block` | A box in the customer profile, right column below Notes and Addresses | `CustomerData` | | `customer.details.action` | An "Apps" button at the top of the customer profile, shown only when an app adds an action here | `CustomerData` | | `customer.index.action` | The "⋯" menu at the end of each customer list row, shown only when an app adds an action here | `IndexData<'customer'>` | | `customer.index.selection-action` | An "Apps" button on the action bar when several customers are selected | `SelectionData<'customer'>` | | `customer.index.column` | A column in the customer list; `values` is keyed by the customer's `id` | `ColumnData<'customer'>` | The profile has the box and the actions wherever it opens: the Customers page, the order form and other screens. When the customer list is filtered to deleted customers, app actions and columns are hidden. ```ts type CustomerRef = { id: string; // use with the Open API: /shops/{shop_id}/customers/{id} name: string; phone_numbers: string[]; tags: (number | string)[]; level_id: string | null; assigned_user_id: string | null; updated_at: string | null; }; type CustomerData = { resource: 'customer'; record: CustomerRef }; // same shape as IndexData<'customer'> ``` - Manifest: the same fields as the target at the same location for other resources (`*.details.action`, `*.details.block`, `*.index.column`…). Customer targets have **no** `conditions`; use `should_render` to hide by data. - `refresh` of a headless action and `pos.refresh()` reload the customer list page being viewed; a profile opened from the list gets the new data. A profile opened from the order form or other screens does not reload yet. - `navigate` adds `customer_id` or `customer_ids` to the query. - When the customer is edited in the profile, the box receives `data-change` with the new data. - As with `*.details.block`, the box's worker starts when the box scrolls into view; POS shows a placeholder meanwhile. ## `*.print-action` The app's print template in the "Print template" picker; users pick it like any other template. The module returns HTML, POS cleans it and opens the browser's print dialog. | Resource | "Print template" picker | |---|---| | `order` | Action menu of the order form (print on save, Print F4) and the print dialog of the bulk action bar | | `purchase`, `export`, `warehouse_transfer` | The Print button on the bulk action bar | Products only print barcodes, so they have no such target. ```ts type PrintResource = 'order' | 'purchase' | 'export' | 'warehouse_transfer'; type PrintData = { resource: R; records: RefOf[] }; // 1–500 records type PrintResult = { html: string; title?: string }; // html up to 2 MB ``` | Manifest | Description | |---|---| | `label` | Template name, up to 40 characters | | `module` | Module returning `PrintResult` | - Runs like a headless action: up to 30 seconds, may use `pos.api` and `pos.toast`. On errors or an invalid result POS reports that printing failed and writes the app's error log. - Records outside the app's `access` limits are not sent to the module. Printing with an app template does not increase the order's print count. - The context data only has ids and summary fields (`RefOf`); fetch lines, customers or amounts from the Open API by record id. - **The HTML is cleaned before printing:** `script`, event attributes (`onclick`…), `iframe`, `form`, `link` and `meta` are removed, and the print runs in an iframe without scripts. Use `${pages.join('')}`, }; }); ``` ## `*.index.column` The app's column in a list, placed before the fixed right column (Status) or at the end of the table. Available for all 5 resources and for customers. POS calls the module **once for the records shown** on the page, not once per row. ```ts type ColumnData = { resource: R; records: RefOf[] }; // records on the page, up to 500 type ColumnResult = { values: Record }; // keyed by record id ``` `values` is keyed by the id used with the Open API (see [Record id](#record-id)): `system_id` for orders, `id` for the other resources. | Manifest | Description | |---|---| | `title` | Column title, up to 30 characters | | `module` | Module returning `ColumnResult` | | `format` | `text` (default), `number`, `money`, `date`, `datetime` | | `align` | `left`, `center`, `right` | | `width` | Width in px, 60–400, default 140 | - Runs like a hook: 5-second timeout, only `pos.api` is available. Batch Open API calls with `Promise.all` or use list endpoints. - Results are remembered per record and update time: when the list reloads (page change, socket updates) the module only runs for new or changed records. - Records missing from `values`, records outside the `access` limits and failed runs leave the cell empty. Strings longer than 200 characters are cut. - App columns are always shown; they are not part of the list's column settings yet. ```ts export default defineExtension('order.index.column', async ({ records }) => { const ids = records.map((order) => order.system_id).join(','); const response = await fetch(`https://erp.example.com/pos-orders?ids=${ids}`); const erp: Record = await response.json(); return { values: erp }; }); ``` --- Source: https://order.pancake.vn/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); ``` ## Standard screens POS lays elements out like other POS pages; the app only picks the right elements and their order, never colors or spacing. - **`app.page` pages:** `pos-page` is the POS page frame. `heading` and `description` go on the title row at the top, the `actions` slot sits on the right next to the primary button (`pos.page.setPrimaryAction`); content is stacked on the gray background. - **Blocks:** a `pos-section` directly in `pos-page` is a white card; a `pos-section` inside another `pos-section` is a flat group; in blocks and modals, `pos-section` is a white card with a thin border. `pos-table` and `pos-chart` placed directly in `pos-page` get a white card automatically. - **Numbers:** `pos-stat` in a 3–5 column `pos-grid`; do not combine `pos-section` and `pos-heading` into metric cards. - **Filters:** put filter inputs in one `pos-filter-bar` at the top of the page or of a section; use `pos-date-range` for periods. For status filters use `pos-tabs` with `badge`: chips with counts like the status bar of POS list pages. - **Buttons:** tool buttons (reload, filter, settings) are icon-only buttons with a `tooltip`; group secondary actions in a `pos-menu-button`. Show statuses in tables with a `format: 'badge'` column; open a row's details in a `pos-drawer` or `pos-modal` with `pos-description-list` and `pos-timeline`. - **Do not** repeat the page title in the content, use emoji or decorative characters in labels (use the `tone` of `pos-stat` and `pos-badge`), nest `pos-section` more than two levels deep, or wrap every element in its own `pos-section`. - Drop all-zero or empty rows from charts and tables; show names instead of ids (look names up through the Open API). ### Report page ```tsx Export e.detail && setRange(e.detail)} /> setState('on_time')} /> setState('late')} /> setCarrier(e.detail.x)} /> ``` ### List page ```tsx Import setSearch(e.detail)} /> setWarehouse(e.detail)} /> setStatus(e.detail)}> setSort(e.detail)} onPageChange={(e) => setPage(e.detail.page)} onCellClick={(e) => setDetail(e.detail.row)} /> ``` ```tsx setDetail(null)}> ``` The page's primary button (for example "Sync") is set with `pos.page.setPrimaryAction` and always comes last on the title row. Export files (CSV, Excel) with [`pos.download`](/developers/apps/en/runtime-api#pos-download). ## 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; cells narrower than 180 px wrap to the next row (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 block. Directly in `pos-page` it is a white card; inside another `pos-section` it is a flat group; in blocks and modals it is a white card with a thin border (see [Standard screens](#standard-screens)). | Property | Type | Description | |---|---|---| | `heading` | `string` | Title | | `description` | `string` | Short text below the title | Slot `actions`: `pos-button`, `pos-link` and `pos-menu-button` 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` | Font size: `1` 20 px (same as the page title), `2` 18 px, `3` 16 px (same as section titles), `4` 14 px. ### 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). ### pos-description-list Label – value pairs like the order info blocks of POS (gray label on the left, value on the right); for modals, panels and detail blocks. | Property | Type | Default | Description | |---|---|---|---| | `items` | `Item[]` | `[]` | Up to 50 rows | | `columns` | `1–4` | `1` | Number of columns; cells narrower than 220 px wrap | ```ts type Item = { label: string; value: string | number | null; // null or empty shows "—" format?: 'text' | 'number' | 'money' | 'percent' | 'date' | 'datetime' | 'badge'; tone?: 'default' | 'info' | 'success' | 'warning' | 'critical'; // color when format is badge }; ``` ```tsx ``` ### pos-timeline A timeline: order journey, change history. | Property | Type | Description | |---|---|---| | `items` | `{ title: string; time?: string; description?: string; tone?: Tone }[]` | Up to 100 steps; an ISO 8601 `time` is formatted like POS, other text is shown as is; `tone` is the dot color | ## 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` | ### pos-date-range A date range like the period picker of the Statistics pages, with quick choices: Today, Yesterday, Last 7 days, Last 30 days, Start of week to now, Start of month to now, Last month. | Property | Type | Default | Description | |---|---|---|---| | `value` | `{ from: string; to: string } \| null` | | Dates as `YYYY-MM-DD` | | `min`, `max` | `string` | | Selectable date limits | | `presets` | `boolean` | `true` | Show the quick choices | Plus the common input properties (`label`, `disabled`, `required`, `error`, `help`). Event `onChange`: `event.detail` is `{ from, to }`, or `null` when cleared. ```tsx e.detail && setRange(e.detail)} /> ``` ## 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'` | ### pos-progress A progress bar for rates (on time, target reached). | Property | Type | Default | Description | |---|---|---|---| | `value` | `number` | `0` | Percentage 0–100 | | `label` | `string` | | Label on the left, percentage on the right | | `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` | Bar color | | `showValue` | `boolean` | `true` | Show the percentage | ### pos-empty An empty state like POS pages: a small illustration, a title, a description and action buttons. | Property | Type | Description | |---|---|---| | `heading` | `string` | Title | | `description` | `string` | Description | Slot `actions` for `pos-button` and `pos-link`. ```tsx Reset filters ``` ## 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` | | | `icon` | see below | | Icon before the text; a button without text is an icon-only button | | `tooltip` | `string` | | Hover text; required for icon-only buttons (also their name for screen readers) | `onClick` event. Icons (the POS icon set): `plus`, `edit`, `delete`, `download`, `upload`, `refresh`, `filter`, `search`, `settings`, `print`, `copy`, `link`, `external`, `view`, `send`, `check`, `close`, `more`, `calendar`, `info`, `warning`. ```tsx Export ``` ### pos-menu-button A button that opens a list of actions. Without `label` it is a "⋯" button. | Property | Type | Default | Description | |---|---|---|---| | `items` | `{ id: string; label: string; danger?: boolean; disabled?: boolean }[]` | `[]` | Up to 30 items | | `label` | `string` | | Button text | | `icon` | as `pos-button` | `more` without `label` | | | `variant` | `'primary' \| 'secondary' \| 'tertiary'` | `'secondary'` | | | `size` | `'small' \| 'medium'` | `'medium'` | | | `disabled` | `boolean` | `false` | | | `tooltip` | `string` | | Hover text | Event `onSelect`: `event.detail` is the `id` of the clicked item. ```tsx run(e.detail)} /> ``` ### pos-table A data table like the POS list tables. Cells only hold values (strings, numbers); how they show (format, badge, link, secondary line) is set on the column. | 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 | | `sort` | `{ key: string; order: 'asc' \| 'desc' } \| null` | `null` | Sorted column (the app sorts the data or calls the API again) | | `summary` | `object \| null` | `null` | Totals row at the bottom, values by column `key`, formatted like the column | | `selectable` | `boolean` | `false` | A checkbox at the start of each row | | `selected` | `(string \| number)[]` | `[]` | Keys of the selected rows | ```ts type Column = { key: string; // field name in the row title: string; align?: 'left' | 'center' | 'right'; width?: number; // px format?: 'text' | 'number' | 'money' | 'percent' | 'date' | 'datetime' | 'badge' | 'link'; tones?: Record; // badge color by cell value subtitle?: string; // field shown as a gray secondary line (for example the SKU under the name) sortable?: boolean; fixed?: 'left' | 'right'; // keep the column visible when scrolling sideways ellipsis?: boolean; // cut long text to one line }; ``` - `percent`: the number is a percentage (12.5 → 12.5%). `badge`: the cell value becomes a colored label using `tones`. `link`: link-styled text that fires `onCellClick`. Events: - `onPageChange`: `event.detail` is `{ page, pageSize }`. - `onRowClick`: `event.detail` is the clicked row. - `onSortChange`: `event.detail` is `{ key, order }`, or `null` when sorting is cleared. - `onSelectionChange`: `event.detail` is the array of selected row keys (rows on other pages stay selected). - `onCellClick`: `event.detail` is `{ key, row }` when a `format: 'link'` cell is clicked. ```tsx setPage(e.detail.page)} /> ``` ```tsx setSort(e.detail)} onSelectionChange={(e) => setSelected(e.detail)} onCellClick={(e) => openOrder(e.detail.row)} /> ``` ### 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`. ### pos-drawer A panel that slides in from the right, like Settings on the Statistics pages: view a row's details without leaving the list. | Property | Type | Default | Description | |---|---|---|---| | `open` | `boolean` | | Open state | | `heading` | `string` | | Title | | `width` | `'small' \| 'medium' \| 'large'` | `'medium'` | 400, 560 or 800 px | Slot `footer` for the buttons at the bottom. Event `onClose` when the user clicks close or outside. ## Worker UI pages The elements below build `render: "worker"` pages and report screens (see [Extension points › app.page](/developers/apps/en/targets#app-page)); they also work in blocks and modals. ### pos-page Page frame. On `app.page` pages, `heading` and `description` show on the POS title row (without `heading`, the title set with `pos.page.setTitle` or the extension `title` is used), the `actions` slot sits on the right next to the primary button, and content is stacked on the gray background. In blocks and modals, `pos-page` is a titled block. | Property | Type | Default | Description | |---|---|---|---| | `heading` | `string` | | Page title | | `description` | `string` | | Text below the heading | | `width` | `'narrow' \| 'default' \| 'full'` | `'default'` | Content width: 720 px, up to 1,800 px or the full screen | Slot `actions` for `pos-button`, `pos-link` and `pos-menu-button`; on `app.page` pages it comes before the primary button. ### pos-tabs A bar of chips with counts (`badge`), like the status bar of POS list pages. The app keeps the selected tab and renders that tab's content as children. | Property | Type | Description | |---|---|---| | `tabs` | `{ id: string; label: string; badge?: number; disabled?: boolean }[]` | Up to 20 tabs; `badge` is a count next to the label | | `selected` | `string` | `id` of the selected tab | The `onChange` event: `event.detail` is the selected tab's `id`. POS switches the tab as soon as it is clicked, without waiting for the app. ### pos-filter-bar A row of filters above a table or chart. Children: `pos-text-field`, `pos-number-field`, `pos-select`, `pos-checkbox`, `pos-date-picker`, `pos-date-range`, `pos-button`, `pos-menu-button`. Inputs are drawn compactly on one row, `label` becomes the hint when there is no `placeholder`, and the row wraps when space runs out. ```tsx setSearch(e.detail)} /> setSource(e.detail)} /> setFrom(e.detail)} /> Reset ``` ### pos-chart A chart drawn by POS; the app only passes data. Colors follow the POS palette. | Property | Type | Default | Description | |---|---|---|---| | `type` | `'line' \| 'bar' \| 'area' \| 'pie'` | `'line'` | Chart type | | `data` | `object[]` | `[]` | One object per point, string or number values; up to 500 points and 21 fields | | `xKey` | `string` | | Field for the horizontal axis (slice names for `pie`) | | `series` | `{ key: string; label: string; format?: 'number' \| 'money' \| 'percent'; type?: 'bar' \| 'line' }[]` | `[]` | Up to 10 series; `pie` uses the first one. A series `type` (`bar`, `line` charts) combines bars and lines | | `selected` | `string \| number \| null` | | Selected horizontal-axis value: other bars and slices fade; line and area charts show a marker | | `stacked` | `boolean` | `false` | Stack bars/areas | | `horizontal` | `boolean` | `false` | Horizontal bars (`bar` only): good for long labels or many groups; the height grows with the number of groups | | `height` | `number` | `300` | 120–600 px | | `loading` | `boolean` | `false` | | | `emptyText` | `string` | | Text when there is no data | - Event `onSelect`: when the app listens, bars, slices and points are clickable; `event.detail` is `{ x, series }`: `x` is the clicked `xKey` value, `series` the `key` of the clicked bar (`null` for `line`, `area`, `pie`). - `format: 'percent'`: values are percentages; when every series is a percentage the value axis shows "%". All series share one value axis, so only combine series of similar size (orders and late orders, not orders and revenue). - Long horizontal-axis labels are shortened to fit, with the full text on hover; when each group gets too narrow, labels are tilted. Drop all-zero groups and merge small ones into "Other" before passing `data`. ```tsx ``` ### pos-stat A metric card for report screens: label, large value, a secondary line and the change from the previous period. Put several cards in a `pos-grid`. On the page background it is a white card; inside a white block (`pos-section`, modal, block) it is a light gray tile. | Property | Type | Default | Description | |---|---|---|---| | `label` | `string` | | Metric name | | `value` | `number \| string \| null` | | Numbers are formatted by `format`; strings show as is; `null` shows `—` | | `format` | `'number' \| 'money' \| 'percent'` | `'number'` | `percent`: `value` is a percentage (58.3 → 58.3%) | | `currency` | `string` | the shop's currency | ISO 4217 code, used with `money` | | `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` | Color of the value | | `help` | `string` | | Secondary line below the value, for example "12 delivered orders" | | `change` | `number` | | Change in %, shown with an arrow; up is good (green), down is bad (red) | | `changeLabel` | `string` | | Note next to the change, for example "vs last week" | | `inverse` | `boolean` | `false` | Flip the colors of `change` for metrics where lower is better (late orders, costs) | | `selected` | `boolean` | `false` | Highlighted border, for a card that acts as a filter | | `loading` | `boolean` | `false` | Shows a placeholder instead of the value | Event `onClick`: when the app listens, the card is clickable (Enter works too), for example clicking "Late" to filter the table below. ```tsx setFilter('late')} /> ``` --- Source: https://order.pancake.vn/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 }; download(file: DownloadFile): 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`, `download` | ✓ | ✓ | ✓ | | ✓ | | `navigate` | ✓ | ✓ | ✓ | | ✓ | | `refresh` | ✓ | ✓ | ✓ | | | | `close` | | | ✓ | | | | `on('data-change')` | ✓ | | ✓ | | ✓ | | `page.*`, `on('primary-action')` | | | | | ✓ | 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.download Downloads a file the app built (CSV, Excel, PDF…) to the user's computer. Code in the worker cannot create download links, so POS downloads it for the app; iframe pages can call it the same way. ```ts type DownloadFile = { filename: string; // the extension sets the type: .csv, .xlsx, .json, .txt, .pdf content: string | ArrayBuffer | ArrayBufferView; // text, or bytes (for example an Excel file from a library) type?: string; // MIME type, when the file name has none of the extensions above }; const csv = ['Order,Customer,Total', ...rows.map((r) => [r.id, r.name, r.total].join(','))].join('\n'); await pos.download({ filename: 'orders.csv', content: csv }); ``` - Up to 10 MB per file. Other types, larger files or a wrong `content` type throw `PosRuntimeError` with `code: 'invalid_download'`. - CSV files get a UTF-8 BOM so Excel reads Vietnamese correctly. - Not available in hooks and `should_render`. ## 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 page's primary button (iframe or Worker UI) | none | ## Worker UI pages A `render: "worker"` page runs in a worker like a block: it uses the global `pos` and `pos-*` elements (see [Components › Worker UI pages](/developers/apps/en/components#worker-ui-pages)). `pos.page` is available on Worker UI pages as on iframe pages: ```ts pos.page.setTitle('Reconciliation 12/05'); pos.page.setPrimaryAction({ label: 'Export Excel', loading: false }); pos.on('primary-action', exportExcel); ``` A `pos-page` `heading`, when set, replaces the `setTitle` title on the title row; `setTitle` still sets the browser tab title. In TypeScript `pos.page` is optional because only pages have it; in a page module use `pos.page!` or `(pos as PosPage).page`. ## 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://order.pancake.vn/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. - AI tools look endpoints up with the `search_api` and `get_api` tools of the [MCP server](/developers/apps/en/ai-guide#mcp): they list only these endpoints, with parameters, response shapes and the `access.methods` each needs. - 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://order.pancake.vn/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). How to run the CLI: - **In a project** created by `create-pos-app`: `npm run dev`, `npm run check`, `npm run build`, `npm run deploy`. Put options after `--`, for example `npm run deploy -- --message "Fix the debt box"`. - **Other commands, or from anywhere:** `npx @pancake-pos/app-cli `, for example `npx @pancake-pos/app-cli versions`. In an installed project this uses the project's own version. - **Short command everywhere:** install globally with `npm i -g @pancake-pos/app-cli`, then run `pos-app `. Do not run `npx pos-app`: the `pos-app` package on npm is not published by Pancake. Outside an installed project, npx downloads and runs that package. 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 | | Standard screens | Follow [Standard screens](/developers/apps/en/components#standard-screens): no emoji or decorative characters in labels; a `pos-section` holding only a heading and a number should be a `pos-stat`; `pos-section` nested at most two levels; icon-only buttons have a `tooltip`; the `pos-page` `heading` does not repeat `pos.page.setTitle` | 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 and Open API endpoints, 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).