docs
/
AI skills

The AppMint JS engine on a page — data, actions and script

Make a site page live with the page runtime that every Appmint site loads — data-* attributes that bind products, records, the cart, the signed-in customer and the URL; data-action buttons that call it; and page JavaScript that calls window.appmint, put in the right slot (the page, root, or a Scripts file) and started at the right moment. Every namespace and method, with the endpoint it calls and what it returns.

Every page on an Appmint-hosted site loads one script, /runtime/appmint.js. It puts window.appmint on the page and scans the HTML for data-* attributes: it fetches what a data-source names, repeats rows, fills data-bind values, and wires data-action buttons to window.appmint methods. Most live pages need nothing else. Page JavaScript is for what attributes can't do, and it must be put in the right slot and started at the right time.

This page is the reference for that engine: the attributes, the expression rules, the timing, every window.appmint method with the endpoint it calls, and three worked examples tested on a real site. For how a page is stored and saved, see Build a website page with components. For shop and product pages, see Catalog pages from your products. For drawers, dialogs, tabs and sliders (wm-*), see Appmint components on a page. For the header, footer and root's site-wide script, see Shared header and footer.

Ask first

  • Which page, and what should be live on it? Name each live part: a product list, a cart button, the customer's name, their orders, a booking picker. Each one maps to an attribute or a method below.
  • Can attributes do it? Try data-source / data-bind / data-action first (step 2). Only write JavaScript for what's left: a quantity box, a multi-step picker, your own rendering.
  • Is the code for this page only, or for every page? One page: its own Page settings. Every page: root's Page settings, or a Scripts file linked from each page (step 1).
  • Does it send anything to real people? A booking, a sign-up, a ticket or an order sends emails (and a booking can send texts). Agree how you will test before anything is submitted (Stop in step 5).

Steps

1. Decide where each piece goes

WhatWhereHow it loads
Live values, lists, buttons that call the platformdata-* attributes in the page HTML (step 2)Wired by the runtime, on every load and after the site's own in-app moves. No script of yours.
Code for one pagethe page's Page settings → Style → JavaScript: the record's top-level style.javascript (step 3)One inline <script>, after the page content, on a full load only
Code for every pageroot's Page settings → Style → JavaScript (style.javascript on the page named root)Same inline <script>, before the page's own code
A long or shared script filea .js file (written in DAM › Scripts, or uploaded), its URL in the page's (or root's) style.scriptLinks (step 4)<script src async>; runs whenever it arrives
Code inside the page HTML (<script> in the head or body)don'tRuns on a full load only, not after an in-app move, not inside root's screens, not with a data-react block

How the site builds the scripts (base-app app/[[...slug]]/page-assets.ts:30-71 and [[...slug]]/layout.tsx:79-92):

  • It walks root → parent page → the page → the site record. From each style it takes styleLinks, css, scriptLinks and javascript.
  • All the javascript strings are joined with nothing between them, then the site's tracking code (Google tag, Meta pixel, cookies) is added, all in one <script>. A syntax error or an uncaught throw anywhere in it stops everything after it: root's code, your page's code and the tracking.
  • Each scriptLinks URL becomes <script src="…" async>, placed before that inline script. async means no order: on the test site a linked file ran about 200 ms after the page's inline code.
  • A URL owned by a built-in feature (for example /login or /account) takes its scripts from the template page it borrows, not from a page of that name.

End every script with ; and wrap root's in try { … } catch (e) { console.error(e) }. Tested: root's code ends })(); and the page's starts (function boot() { with nothing in between. Without the ;, the second function would be called with the first one's return value.

Hands on: a list of the live parts and, for each, whether it's an attribute, page code, root code or a script file. Check: nothing is planned as a <script> inside the page HTML or inside root's screens.

2. Build Studio: write the markup with the runtime's attributes

Write the page HTML the way Build a website page with components stores it (page_write with html). Put the live parts in with attributes:

<section id="grid" data-source="product.list"
         data-source-payload='{"category":"{{$query.category}}","limit":24}'>
  <article data-bind-each>
    <h3 data-bind="data.name"></h3>
    <p data-bind="data.price" data-format="currency"></p>
    <button data-action="cart.add" data-payload="this">Add to cart</button>
  </article>
</section>
<button data-action="cart.toggle">Cart <span data-bind="cart.count" data-format="parentheses"></span></button>
  • data-source="ns.method" calls that method once with the parsed data-source-payload. An array result repeats each data-bind-each child once per row. Inside a row, paths read the row first (data.name, sk), then the source, then page state.
  • data-bind reads page state anywhere: cart.count, site.title, query.ref, customer.current.data.email (full list under State you can bind below).
  • data-action calls a method on click (submit for a form, change for an input), with data-payload as its one argument.

The full syntax is in the reference sections below: Attributes, Payloads and {{…}}, Expressions, Effects, Formats, Triggers.

Expressions can't call JavaScript globals. Every name in an expression is turned into a local and filled from the row or page state, so Number, Math, parseInt, JSON and Date are undefined and the element renders empty, with no error. Tested: Number(data.price) and Math.round(data.price) came out blank; data.price.toFixed(2) ("49.50"), .toLocaleString() and data.price|0 worked. Use data-format for money, dates and numbers (base-app src/runtime/dispatcher.ts:791-826).

Hands on: the page's id and its HTML, with every live part as an attribute. Check: repo_find_one on the page: the attributes are in data.screens[0].html and data.html, unchanged (no this. inside {{…}}, no JS globals in expressions).

3. Page settings → Style: the page's JavaScript

Write it with page_write: { "id": "<sk>", "javascript": "…" }. It appends to what is there; to change it, pass the whole script with replaceStyle: true. Start every page script this way:

(function boot() {
  // 1. Never on the Build Studio canvas: DOM built here would be saved into the page.
  if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
  // 2. Wait for your own element AND the runtime part you call.
  var app = document.getElementById('my-app');
  if (!app || !(window.appmint && window.appmint.cart)) return void setTimeout(boot, 30);
  // 3. Run once, even if the script is in the page twice.
  if (app.dataset.bound) return;
  app.dataset.bound = '1';
  // …your code: addEventListener, textContent for record values…
})();

Why each line is there (tested on the local site):

MomentWhat exists
Your inline script runswhile the HTML is still loading (document.readyState is loading). The page's content and root's header and footer are already in the DOM above it. window.appmint is not there yet.
React hydrates the pagethe page content sits inside a wrapper marked data-appmint-hydrating until then (page-client.tsx:132)
/runtime/appmint.js runsafter hydration (<Script strategy="afterInteractive">, app/layout.tsx:122). It sets window.appmint, asks who the customer is, seeds site, page, query and path, mirrors the cart, and starts the attribute scanner (src/runtime/index.ts:315-340).
The scanner fills attributeson start, then again for anything added to the page (a MutationObserver) and when the hydrating marker is removed. It never touches a subtree still marked data-appmint-hydrating (dispatcher.ts:64-115).
  • Wait for the shared chrome too, when you use it. A site whose root script sets up helpers waits for them: PhaZZer's page scripts wait for document.getElementById('pzfoot') and window.PZ.booted, which root's script sets last. Wait for whatever root defines, not a fixed time.
  • Your inline script runs only on a full page load. When the site's own React screens move the visitor without a reload (for example the redirect back from /login), the script tag is put in but not run. Tested: after signing in, the page's code did not run; its data-* attributes still worked. Anything that must work after sign-in belongs in attributes (worked example B). Plain <a href> links in your HTML do a full load.
  • The canvas. On Build Studio's edit canvas the body has data-design-mode="true" and the runtime isn't loaded; the preview loads both the runtime and your page JavaScript (websitemint components/common/iframe-portal/iframe-canvas.tsx:301-313, components/build-studio/full-html-doc.ts:157).

A data-action click never reaches your code. The runtime calls preventDefault() and stopPropagation() on every event it handles (dispatcher.ts:199-201). A listener on a parent never sees clicks on data-action elements, an <a href> with data-action doesn't follow its link, and a checkbox with data-trigger="click" won't tick. Give your own buttons no data-action, as the shop example does.

Hands on: the page's style.javascript. Check: repo_find_one shows style.javascript at the top level (not data.style), it parses, and it starts with the design-mode guard.

For a long script, or one several pages share, keep it as a file.

  1. DAM › Scripts (Studio route /client-apps) → new script. Name: lowercase letters, digits, - or _, starting with a letter; it can't be renamed later. Write the code in the editor or upload a .js / .mjs.
  2. Save. The editor uploads the code to <org>/client-apps/<name>/<name>.js and keeps a client_app record (name, description, status, code, entryPoint = the file's URL, sourceFile, runtime: "appmint-js") (websitemint components/configuration/client-apps/client-app-editor.tsx:128-170). The URL it shows is entryPoint.
  3. Link it. Add that URL to the page's style.scriptLinks (page_write { "id": "<sk>", "scriptLinks": ["<url>"] }; it merges with links already there). For every page, put it in root's scriptLinks.

An agent can skip the editor: save_page_asset with path: "client-apps/<name>/<name>.js" and content writes the same file publicly with application/javascript and returns its url (with a ?ts= cache-buster); or upload it with call_appengine POST repository/file/upload (multipart file, and location set to the folder). Then page_write the URL into scriptLinks.

Saving in Scripts doesn't put the script on any page. Nothing on the site reads the client_app record; a script loads only from a scriptLinks URL. The record's Status (Draft / Active / Disabled) changes nothing on the site: a Disabled script still loads wherever it is linked, and deleting it from the list leaves the file and the links. Build Studio's block panel reads active scripts but never offers them as blocks (components/preset/web/index.tsx:33-71).

  • A linked file runs async, in no fixed order, before or after your inline code and before or after window.appmint. Start it with the same boot() wait as step 3. Tested: the file ran at 747 ms, after the page's inline code (540 ms), and window.appmint wasn't there yet.
  • Re-saving keeps the same URL. Browsers and the storage CDN may serve the old copy; after a change, add ?v=2 to the URL in scriptLinks.
  • Check the file is served as JavaScript. curl -sI <url> should say content-type: application/javascript. The test upload did; a file served as application/octet-stream can be refused.
  • The Scripts editor's API hints show appmint.cart.add({ productId: '', quantity: 1 }). That adds a nameless $0 line: the cart reads id / sku, name and unitPrice, never productId (client-apps/script-api-reference.ts:25, base-app src/runtime/namespaces.ts:122-153).

Which to use:

style.javascript (page)root's style.javascriptScripts file in scriptLinks
Runs onthat pageevery pageeach page that links it
Orderafter root's, before the site record's and the trackingfirstunordered (async)
A syntax errorstops root's, the page's and the tracking that come after it in the joined scriptstops everything on every pagestops only that file
EditPage settings, or page_write (appends)root's Page settingsthe Scripts editor, or save_page_asset to the same path
Use forthat page's widgetmenu, sticky header, helpers every page useslong code, code shared by a few pages

Hands on: the file's URL, and the pages whose scriptLinks now carry it. Check: repo_find_one on each page: style.scriptLinks contains the URL. curl -sI on the URL answers 200 with a JavaScript content type.

5. Check it on the site

Open the page at https://<site host>/<slug> in a real browser (or headless Chrome), not the page source.

  • Every data-source has rows ([data-each-rendered] elements), every data-bind has text, and no element carries data-error.
  • One click on each button does one thing: one cart line, one count change, one request.
  • The browser console has no [appmint] warnings (data-source failed, unknown effect) and no errors.
  • Sign in and out (the site's /login), and check the parts that depend on the customer.
  • Open the page in Build Studio: the canvas shows the page, and your script hasn't added anything to it.

Stop: don't submit a real booking, sign-up, ticket or order to test on a live site: each one sends real emails, and a booking can also text the customer. Show the person the page, then test submission with them, with their own address, or stop at the step before submit.

Worked examples

All three were run on a local copy of a real site (PhaZZer), rendered in headless Chrome and checked.

A. Product grid with a quantity box and add-to-cart (window.appmint.cart)

Markup (the page HTML):

<main id="sj-shop">
  <button id="sj-cart" data-action="cart.toggle">Cart <span data-bind="cart.count" data-format="parentheses"></span></button>

  <section id="sj-grid" data-source="product.list"
           data-source-payload='{"category":"{{$query.category}}","limit":24,"sort":"name","sortType":1}'>
    <article class="sj-card" data-bind-each>
      <a data-bind-attr-href="'/product?slug=' + data.slug">
        <img data-bind-attr-src="data.image" data-bind-attr-alt="data.name">
        <h3 data-bind="data.name"></h3>
      </a>
      <p data-bind="data.price" data-format="currency"></p>
      <label>Qty <input class="sj-qty" type="number" min="1" value="1"></label>
      <button class="sj-add" type="button"
              data-bind-attr-data-id="sk" data-bind-attr-data-sku="data.sku"
              data-bind-attr-data-name="data.name" data-bind-attr-data-price="data.price"
              data-bind-attr-data-image="data.image">Add to cart</button>
    </article>
  </section>
  <p><span data-bind="catalogTotal"></span> products</p>

  <ul data-loop="cart.items">
    <li data-bind-each><span data-bind="name"></span> × <span data-bind="quantity"></span></li>
  </ul>
  <p>Total <span data-bind="cart.total" data-format="currency"></span></p>
</main>

Page JavaScript (Page settings → Style):

(function boot() {
  if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
  var app = document.getElementById('sj-shop');
  if (!app || !(window.appmint && window.appmint.cart)) return void setTimeout(boot, 30);
  if (app.dataset.bound) return;
  app.dataset.bound = '1';
  // One listener for every card, including cards the runtime adds later.
  app.addEventListener('click', function (e) {
    var btn = e.target.closest('.sj-add'); if (!btn) return;
    var qty = parseInt(btn.closest('.sj-card').querySelector('.sj-qty').value, 10) || 1;
    btn.disabled = true;
    window.appmint.cart.add({
      id: btn.dataset.id, sku: btn.dataset.sku, name: btn.dataset.name,
      unitPrice: Number(btn.dataset.price), quantity: qty, image: btn.dataset.image
    })
      .then(function (cart) { btn.textContent = 'Added (' + cart.itemCount + ' in cart)'; window.appmint.cart.open(); })
      .catch(function (err) { btn.textContent = 'Could not add: ' + (err && err.message); })
      .then(function () { btn.disabled = false; });
  });
})();
  • The runtime renders the cards, and data-bind-attr-data-* copies each product's fields onto its button, so page code reads them from dataset with no lookups.
  • cart.add puts the line in the site's cart, prices the cart on the server (POST storefront/pricing/calculate-cart), and resolves with the cart summary.
  • Tested: two cards with links, images and prices; quantity 3 on a $49.50 product gave count (3), total $148.50, the line "… × 3", the cart drawer open, and the cart was still there on the next page.
  • For quantity 1 with no code at all, <button data-action="cart.add" data-payload="this"> inside the card does the same.

B. The customer's name and "My orders" (attributes only)

<main id="acct-app">
  <!-- Re-read the customer once the page is live; then reload the orders. -->
  <span hidden data-action="customer.current" data-trigger="load" data-on-success="refresh:#acct-orders"></span>

  <section data-hide="customer.current.data.email">
    <p>Sign in to see your orders.</p>
    <a href="/login?redirect=/my-orders">Sign in</a>
  </section>

  <section data-show="customer.current.data.email">
    <h1>Hello, <span data-bind="customer.current.data.firstName || customer.current.data.email"></span></h1>
    <button data-action="customer.signOut" data-on-success="reload">Sign out</button>
    <ul id="acct-orders" data-source="order.list" data-source-payload='{"limit":20}'>
      <li data-bind-each>
        #<span data-bind="data.number"></span>
        <span data-bind="createdate" data-format="date"></span>
        <span data-bind="data.status" data-format="capitalize"></span>
        <span data-bind="data.amount" data-format="currency"></span>
      </li>
    </ul>
    <p id="acct-empty" hidden>No orders yet.</p>
  </section>
</main>

Optional page JavaScript, for a count and the empty message, using the runtime's events:

(function boot() {
  if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
  var list = document.getElementById('acct-orders');
  if (!list || !(window.appmint && window.appmint.order)) return void setTimeout(boot, 30);
  if (list.dataset.bound) return;
  list.dataset.bound = '1';
  list.addEventListener('appmint:source-loaded', function (e) {
    var rows = Array.isArray(e.detail.data) ? e.detail.data : [];
    document.getElementById('acct-empty').hidden = rows.length > 0;
  });
  list.addEventListener('appmint:source-error', function () {   // 403 when signed out
    document.getElementById('acct-empty').hidden = true;
  });
})();
  • Bind customer.current.data.*. For a signed-in visitor customer.current is the customer record ({ sk, data: { email, firstName, … } }), not flattened. For everyone else it is a plain string ("app@demo" on the test site), so customer.current.data.email is empty and data-hide shows the sign-in block.
  • The load action is what makes it work after sign-in. The sign-in page sends the visitor back without a reload, so page JavaScript doesn't run. The hidden customer.current action does, and its refresh:#acct-orders fetches the orders again with the new sign-in.
  • order.list reads client-data/orders (the signed-in customer's own). Signed out it answers 403 "Customer identity is required", and the list stays empty.
  • createdate is on the record, not under data.
  • Tested: signed out, the sign-in block; after /login, "Hello, Skill", one row "#SKJS00001 10/20/2026 Paid $169.50"; Sign out went back to the signed-out block.
  • The runtime's first fetch can land before page code has attached its listener. Here the refresh after customer.current fetches again, so the event comes; on a page without it, read the rows already rendered as well.

C. A booking widget (window.appmint.reservation)

The booking type (a reservation_definition) is set up first: see Take bookings with reservations and Bookable resources for a booking type. Two fields decide whether slots come back:

FieldRule
data.workDaysFull English names, capitalised: ["Monday","Tuesday",…]. The server compares exactly; with "monday" every day is closed and slots answer { slots: [], closed: true, error: "We are closed on this day…" } (appengine crm/reservations.service.ts:698-709, util/helpers.ts:160). Tested both ways.
data.services[]Each { name, duration, price }. The booking's service is matched against a service's name or id, trimmed, any case (reservations.service.ts:1349-1360), so a separate id is no longer needed.
data.officeHours.timezoneSlots are worked out in it; show times in it too

Markup:

<main id="bk-app" data-definition="fitting">
  <label>Service <select id="bk-service"></select></label>
  <label>Date <input id="bk-date" type="date"></label>
  <div id="bk-slots"></div>
  <p id="bk-msg"></p>
  <form id="bk-form" hidden>
    <input name="name" placeholder="Your name" required>
    <input name="email" type="email" placeholder="Email" required>
    <input name="phone" placeholder="Phone">
    <button id="bk-submit">Book</button>
  </form>
  <p id="bk-done" hidden>Booked. Your confirmation number is <strong id="bk-number"></strong>.</p>
</main>

Page JavaScript:

(function boot() {
  if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
  var app = document.getElementById('bk-app');
  if (!app || !(window.appmint && window.appmint.reservation)) return void setTimeout(boot, 30);
  if (app.dataset.bound) return;
  app.dataset.bound = '1';

  var R = window.appmint.reservation;
  var $ = function (id) { return document.getElementById(id); };
  var def = null, picked = null;
  var say = function (t) { $('bk-msg').textContent = t || ''; };
  var tz = function () { return (def.data.officeHours && def.data.officeHours.timezone) || undefined; };
  var timeLabel = function (iso) {
    return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZone: tz() });
  };

  // 1. The booking type named on the page.
  R.definitions().then(function (rows) {
    def = rows.filter(function (d) { return d.data && d.data.name === app.dataset.definition; })[0];
    if (!def) return say('This booking type is not available.');
    (def.data.services || []).forEach(function (s) {
      var o = document.createElement('option');
      o.value = s.name; o.textContent = s.name + (s.duration ? ' (' + s.duration + ' min)' : '');
      $('bk-service').appendChild(o);
    });
    $('bk-date').value = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
    loadSlots();
  }).catch(function (e) { say('Could not load booking types: ' + e.message); });

  // 2. Open slots for the service and date.
  function loadSlots() {
    picked = null; $('bk-form').hidden = true; $('bk-slots').textContent = ''; say('Loading times...');
    R.availableSlots({ reservationDefinitionId: def.sk, serviceName: $('bk-service').value, serviceDate: $('bk-date').value })
      .then(function (res) {
        var slots = (res && res.slots) || [];
        if (res && res.closed) return say(res.error || 'Closed on this day.');
        if (!slots.length) return say('No times left on this day.');
        say('');
        slots.forEach(function (s) {
          var b = document.createElement('button');
          b.type = 'button'; b.className = 'bk-slot'; b.textContent = timeLabel(s.startTime);
          b.addEventListener('click', function () { picked = s; $('bk-form').hidden = false; });
          $('bk-slots').appendChild(b);
        });
      })
      .catch(function (e) { say('Could not load times: ' + e.message); });
  }
  $('bk-service').addEventListener('change', loadSlots);
  $('bk-date').addEventListener('change', loadSlots);

  // 3. Book the picked slot.
  $('bk-form').addEventListener('submit', function (e) {
    e.preventDefault();
    if (!picked) return say('Pick a time first.');
    var f = e.target, btn = $('bk-submit');
    btn.disabled = true; say('Booking...');
    // A visitor books without signing in through crm/reservations/book (reservation.create is the signed-in customer's own).
    fetch('/api/crm/reservations/book', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        reservationDefinitionId: def.sk, service: $('bk-service').value,
        startTime: picked.startTime, endTime: picked.endTime,
        customerName: f.name.value, customerEmail: f.email.value, customerPhone: f.phone.value
      })
    }).then(function (r) {
      return r.json().then(function (j) {
        if (!r.ok) throw new Error((j && j.error && j.error.error) || 'Booking failed');
        return j;
      });
    }).then(function (rec) {
      $('bk-number').textContent = (rec && rec.data && rec.data.name) || '';
      f.hidden = true; $('bk-slots').textContent = ''; say(''); $('bk-done').hidden = false;
    }).catch(function (err) {
      say('Could not book: ' + err.message);
    }).then(function () { btn.disabled = false; });
  });
})();
  • reservation.definitions() → GET crm/reservations/definitions, open to visitors; rows are { sk, data: { name, title, services, workDays, officeHours, … } }.
  • reservation.availableSlots({ reservationDefinitionId, serviceName, serviceDate }) → POST client-data/reservations/available-slots. serviceDate is YYYY-MM-DD in the business's timezone (date, appointmentDate, startDate and day are accepted too). It answers { reservationDefinition, service, location, slots: [{ startTime, endTime, spotsAvailable, businessTimezone }] }.
  • The booking is POST /api/crm/reservations/book, the call the site's own booking page makes, open to visitors. It takes flat customerName / customerEmail / customerPhone or a nested customer: { name, email, phone } (a name and an email or phone are required: 400 contact_required); endTime defaults to the service's length. It answers the new reservation; data.name is the confirmation number. One contact can book 3 times an hour from the site (429 too_many_bookings) (reservations.controller.ts bookFromSite). reservation.create(…) (POST client-data/reservations) takes the same body but only for a signed-in customer: a visitor gets 401 "Not signed in". The runtime has no book method, hence the fetch.
  • Tested: the service list filled, 16 half-hour slots from 9:00 AM (Chicago) for the next day, and picking 10:00 AM posted {"reservationDefinitionId":"…","service":"Fitting","startTime":"2026-10-21T15:00:00.000Z","endTime":"2026-10-21T15:30:00.000Z","customerName":…}. The booking call was intercepted in the browser and answered with a fake record, because a real one emails the customer and the business and can text the customer (reservations.service.ts:1563-1626). That test ran against reservation.create; the book call above takes the same body and is read from the code, not tested.

Reference: attributes the runtime reads

AttributeOnWhat it does
data-source="ns.method"a containerCalls the method once with data-source-payload. An array result: each data-bind-each template is cloned once per row. One object: the container's descendants bind to it. Re-fetches when a state key named in the payload changes, or on refresh:#id. (dispatcher.ts:505-644)
data-source-payload='{…}'with data-sourceJSON after {{…}} is filled in; not valid JSON → passed as a string
data-loop="path"a containerLike data-source, but the rows are an array already in state or in the row (cart.items, data.options). Re-renders when that top-level state key changes. (dispatcher.ts:128-181)
data-bind-each (or data-repeat="true")a childThe row template. Hidden until rendered; clones get data-each-rendered. Several sibling templates are cloned in order per row.
data-bind="expr"anySets textContent (replaces the element's children)
data-bind-html="expr"anySets innerHTML, not sanitized. BlockNote JSON is turned into HTML.
data-bind-attr-<name>="expr"anySets the attribute; null, undefined or false removes it. data-bind-attr-data-id sets data-id.
data-bind-style-<prop>="expr"anySets that style (data-bind-style-color, data-bind-style-background-color). Use the dashed CSS name: HTML lowercases attribute names, so …-backgroundColor arrives as backgroundcolor and sets nothing.
data-bind-class="expr"anyA string or array replaces className; an object { "on": cond } adds and removes only those classes
data-show="expr" / data-hide="expr"anySets style.display to '' or none. Falsy: null, false, 0, '', [].
data-format="…"with data-bindSee Formats
data-action="ns.method"anyCalls the method with the payload (see Triggers for when)
data-payloadwith data-actionSee Payloads
data-on-success / data-on-errorwith data-actionEffects, joined with |
data-triggerwith data-actionThe event; see Triggers
data-confirm="text {{…}}"with data-actionwindow.confirm first; Cancel stops it
data-no-validatewith data-action in a formSkips the HTML5 checkValidity() gate
data-media-target="#id"with media.*Names the <audio> / <video>
data-form-store="<id>"a <form>form.* reads an @appmint/form store instead of the fields
<script type="appmint/blocknote-block" data-type="x">headIts body returns a renderer for BlockNote block type x (dispatcher.ts:679-702)
  • data-show doesn't remove hidden. It only sets style.display, so <p hidden data-show="…"> never shows. Don't put hidden on a data-show element.
  • Rendered rows go after everything else in the container. On every render the old rows are removed and the new ones appended at the end, after any static children. Keep a source container for rows only, and put headings and counts outside it.
  • Other elements inside an array source don't see it. Tested: <span data-bind="length"> beside the rows stayed empty. Bind catalogTotal, or set state from the appmint:source-loaded event.
  • data-bind-class="'pill ' + x" (a string) wipes the element's own classes. Use the object form to keep them.
  • data-loop over plain strings or numbers renders empty rows. Tested: data-bind="this" on a row of ["a","b"] showed nothing. Loop over objects ([{ "label": "a" }]).
  • data-error is set when a source fails and not removed when a later fetch succeeds.

Reference: payloads and {{…}}

data-payload resolves to one value, the method's only argument (dispatcher.ts:364-405):

data-payloadPasses
(none)a <form>: its fields as an object; an <input>/<select>/<textarea>: its value (a number for number/range, true/false for a checkbox); inside a row: the row; else undefined
formthe containing form's fields (repeated names become arrays)
thisthe row inside data-bind-each, or the record inside a single-object data-source (tested with product.get); else the element's value
this.value, this.checked, …that property of the element, not the row
'{"id":"{{sk}}","quantity":2}'JSON, after {{…}} is filled in
'"storefront/config"'a JSON string
{{data.sku}} or data.skuthe value at that path, its own type

Inside {{…}} (dispatcher.ts:871-924): a path (row, then source, then state; in an effect, the action's result first), $query.<name> (URL parameter), $path.<n> (path segment, counted from 1), $json:<path> (JSON-encoded, with its own quotes), $json:this, $json:$result, $result (the result as text) and this (the row as text).

  • Paths are bare: {{sk}}, {{data.name}}, never {{this.sk}}. Tested: {"id":"{{this.sk}}","name":"{{this.data.name}}","unitPrice":1} added a nameless $1 line with an empty id, and reported success.
  • Text into JSON goes through $json:. "name":"{{data.name}}" broke on a product called Skilltest JS "Pro" Kit: the JSON didn't parse, the payload fell back to undefined, and the click failed with "Cannot read properties of undefined (reading 'cartItemId')". "name":{{$json:data.name}} (no quotes around it) worked for both products.
  • Numbers go in unquoted ("unitPrice":{{data.price}}). If the value is empty the JSON breaks the same way.
  • {{$path.1}} is the first path segment; the bindable state path.0 is also the first. They count differently.
  • {{this}} in a string gives [object Object]. Use {{$json:this}}.

Reference: expressions

A data-bind, data-show, data-hide, data-bind-* value is a plain path (data.name, cart.count) or a JavaScript expression (data.stock > 0 ? 'In stock' : 'Sold out'). {{…}} inside it is filled in first (dispatcher.ts:791-826).

  • Names come from the row, then the source, then page state. Every identifier becomes a function parameter filled that way. That is why globals (Number, Math, JSON, Date, window, document) are undefined.
  • Methods on values work: data.price.toFixed(2), data.name.toUpperCase(), (data.tags || []).join(', '), .toLocaleString().
  • A word in a string literal counts as a name. with or enum anywhere in an expression, even inside quotes, makes it invalid, and the binding goes blank. Tested: 'enum' blanked it; other words were fine.
  • this in an expression is the row.
  • Re-rendering: each binding re-runs when a top-level state key it names changes (cart for cart.count). Values inside a row change only when its source fetches again.
  • Errors inside an expression give undefined (empty), never an exception.

Reference: effects

data-on-success="a | b". The text after : can use {{…}}, read from the action's result first (dispatcher.ts:436-484).

EffectDoes
navigate:/path?x={{sk}}window.location.href = … (a full load)
reloadreloads the page
refresh:#idfetches that element's data-source (or re-renders its data-loop) again
set-state:key=valuetrue / false / null / numbers are typed, anything else is text. set-state:count={{itemCount}} after cart.add gives the new count.
reset-formresets the closest form
focus:#idfocuses that element
close-dialogremoves open from the closest open wm-dialog / wm-drawer
open-dialog:#idsets open on it
notify:text / notify-error:textfires a window event appmint:notify

With no data-on-error, a failure runs notify-error:<message>. {{message}} in data-on-error is the error message.

  • notify and notify-error show nothing. Nothing on the site listens for appmint:notify. Tested: no toast. Show messages with set-state:msg=Saved and <p data-show="msg" data-bind="msg">. (cart.add shows its own "Item added to cart" toast.)
  • A bare refresh does nothing. It fires on window; sources listen on their own element. Always name the target: refresh:#list.
  • {{$result.x}} is empty. Write {{x}}: effect paths already read the result first. (The runtime's manifest shows {{$result.id}}; it doesn't work.)
  • open-dialog / close-dialog don't really open or close wm-dialog / wm-drawer: use ui.dialog / ui.drawer (see Appmint components on a page).

Reference: formats

data-format on a data-bind element (dispatcher.ts:948-967). null / undefined → empty.

FormatGives
currency$49.50; another currency with data-format-opt-currency="EUR" (€49.50)
number1,234.5
percent0.25 → 25%
date / datetimethe browser's local date / date and time
parentheses3 → (3); 0 → empty (for cart counts)
uppercase / lowercase
capitalizefirst letter up, the rest lower: "Skilltest JS Lantern" → "Skilltest js lantern"

Unknown formats print the value as it is.

Reference: triggers

data-triggerFires
(none)submit on a form, change on an input/select/textarea, click on anything else
any DOM event namethat event (input, mouseenter, keyup, …)
loadonce, as soon as the element is wired
inview / visibleonce, when 25% of it is on screen
  • hover never fires. It is not a DOM event, though the runtime's manifest lists it. Use mouseenter.
  • load runs before the page's own state is in. Tested: data-action="activity.view" data-payload='{"datatype":"page","id":"{{page.sk}}"}' data-trigger="load" called stats/page/view with no id (404). Put the id in the HTML, or call it from page code once appmint.state.get('page') is set.
  • A load action inside a row runs again each time the rows render.
  • A form submit, or a button in a form, first runs the browser's validation; an invalid form stops the action and shows the browser's message.

Reference: events and markers

Event / attributeWhereWhen
appmint:source-loadedthe data-source elementthe fetch answered; e.detail = { data, action }
appmint:source-errorthe data-source elementthe fetch failed; e.detail = { error, action } (the error has code, status, message)
appmint:refreshdispatch it on a data-source / data-loop elementfetch / render it again (what refresh:#id does)
appmint:notifywindownotify effects; nothing listens
data-loadingthe action / source elementwhile the call runs
data-error="message"the source elementafter a failed fetch
data-each-renderedeach rendered rowalways
data-appmint-hydratingthe page wrapperuntil React has hydrated; the runtime ignores what is inside

Page code can also watch state: appmint.state.subscribe('cart', fn) (fires on cart and anything under it).

Reference: state you can bind

PathHoldsSet by
site.title, site.name, site.<field>, site.skthe site record, flattened (site.title, not site.data.title)runtime start (index.ts:277-313)
company.name, company.<field>the org's company record, flattenedfrom the site
page.name, page.title, page.slug, page.skthe page record, flattenedruntime start (a request, so not there at first)
query.<name>, queryURL parametersstart and every history change
path.0, path.1, path.last, path.value, path.lengthpath segments (from 0), the full pathstart and every history change
cart.count, cart.total, cart.items, cart.isOpenthe site's cart; total is the server's figurethe cart mirror (index.ts:168-191)
customer.currentsigned in: the customer record, { sk, data: { email, firstName, lastName, … } }; otherwise a plain stringcustomer.current()
customer.signedIntrue for everyone (see warning)customer.current()
catalogTotal, catalogPriceRange.minPrice / .maxPricefrom the last product.listproduct.list
anythingyour ownset-state:, appmint.state.set()

customer.signedIn and appmint.customer.isSignedIn() are true for visitors who aren't signed in. The site's API proxy answers user/whoami for a visitor with its own app identity ("app@demo" on the test site), and the runtime stores that as the customer (namespaces.ts:696-708). Test customer.current.data.email instead.

Reference: window.appmint

Every call goes through the site's own /api/<path> proxy (the host's API client, with the org, the app token and the visitor's sign-in cookie), so pages never hold a token. A call made before the site's React layer is up falls back to a direct request (transport.ts:99-111); waiting for window.appmint as in step 3 avoids that in practice. A failed call rejects with an AppmintError: code is unauthenticated (401), forbidden (403), not-found (404), conflict (409), validation (422), rate-limited (429), server (5xx) or network, plus status and details (src/runtime/transport.ts:85-97,180-188). message is often the generic "Request failed with status code 403"; the server's message is in details.

Every method takes one argument when called from data-action (the payload). "List" methods return the row array (they strip { data: [...] }); the others return the server's answer as it is. ✓ = tested on the local site.

cart: the site's cart (local, server-priced) (namespaces.ts:350-461)

MethodDoesReturns
add(item) ✓Adds a line. Takes a cart line { id, sku, name, unitPrice, quantity, image, attributes?, description? } or a product record ({ sk, data: { sku, name, price, image } }, what this gives). Lines with the same id, SKU and chosen attributes merge. Then recalc().the summary below
update({ id, quantity }) / update(id, qty) ✓id is the line's cartItemId (or a product id / SKU); quantity a number, "increment" or "decrement"the summary
remove(id) / remove({ id }) ✓removes the linethe summary
clear() ✓empties the cart and its pricingnothing
current() ✓{ id: '', items, subtotal, total, tax, shipping, discount, itemCount }
recalc()POST storefront/pricing/calculate-cart with the lines, coupon and shipping address; folds the server's prices backnothing
count() / total()a number
toggle() ✓ / open() ✓ / close()the site's cart drawernothing

A line's id is cartItemId, product-<id>-<sku>-<chosen attributes>. In a data-loop="cart.items" row, change quantity with data-action="cart.update" data-payload='{"id":"{{cartItemId}}","quantity":"increment"}' (tested). Never pass the product's own attributes list (the option schema) as a line's attributes; the runtime drops it. The cart is kept in the browser (cart-storage), so it survives page loads. With Storefront (and Rental) off, the cart drawer isn't on the page and toggle does nothing.

product: the storefront catalog (namespaces.ts:510-537)

MethodCallsReturns
list(opts) ✓GET storefront/products. limit/pageSize → ps, page → p, sort → s, sortType → st, lastItem → l; category/categories, tag/tags, brand, keyword, price ("10-50"), minPrice, maxPrice pass through; any other key filters on a product attribute. Empty values are dropped.product records; sets catalogTotal and catalogPriceRange
get(id | { id | sku | slug }) ✓GET storefront/product/<id>one product. An unknown slug came back as success, not an error.
related(id | { id, limit }) ✓GET storefront/product/<id>/relatedproducts
categories() ✓GET storefront/categoriesrows (empty on the test site)
brands(name?), attributes(name?), collections(name?)GET storefront/brands/…, storefront/attributes/…, storefront/collections/…the server's answer

A product row has sk and data.{ name, slug, sku, price, image, images, description, categories, tags, attributes }, and data.calculatedPrice as an object { originalPrice, finalPrice, discount, discountPercent, appliedDiscounts } (bind data.calculatedPrice.finalPrice, not the object). Keyword search works ("keyword":"{{kw}}", fetches again when kw changes; tested).

order: the signed-in customer's orders (namespaces.ts:542-560)

MethodCallsReturns
list(opts?) ✓GET client-data/ordersorder records; 403 when not signed in
get(number) ✓GET client-data/orders/<number>one order
myOrders() ✓GET client-data/ordersthe whole answer ({ data: [...], … }), not an array
getByNumber(number)GET storefront/order/get/<user>/<number> (signed in)one order
guestLookup({ email, orderNumber })GET storefront/order/email/<email>/<number>one order
cancel(number, reason)POST storefront/order/cancel/<number> { reason }the order. Only the signed-in customer's own order (401 signed out, 403 someone else's)
refund(number, input)POST storefront/order/refund/<number>staff only: a customer gets 403 "A refund is made by the business. Ask the store to refund this order." Don't put it on a site page

An order row: createdate, data.{ number, status, amount, items, … }.

customer: sign-in and the customer's own data (namespaces.ts:679-790)

MethodCallsReturns
current() ✓the sign-in cookie's user; if none, GET user/whoamithe customer record, or the proxy's app identity string. Stored in customer.current.
isSignedIn() ✓true even when not signed in (see warning above)
signIn({ email, password })the site's loginfails for visitors who aren't signed in ("Cannot read properties of null (reading 'orgId')", no request sent). Link to /login?redirect=<page>.
signUp(input)the site's registrationnot tested (sends a welcome message); see Registration that creates the customer
signOut() ✓clears the sign-in, POST user/signoutnothing; pair with data-on-success="reload"
update(patch)POST user/customer/updatethe customer
changePassword(input), resetPassword({ email }), subscribe(input), unsubscribe(input)POST user/customer/password/change, the site's reset email, user/customer/subscribe, …/unsubscribenothing
dashboard() ✓, profile() ✓, fullData(), analytics(), transactions(opts)GET client-data/dashboard, /profile, /all, /analytics, /transactionsthe server's answer (signed in)
updateProfile(patch)PUT client-data/profilethe server's answer
addresses(), addAddress(a), removeAddress(id), autocompleteAddress(input), resolveAddressPlace(placeId)client-data/addresses…
paymentMethods(), addPaymentMethod(input), removePaymentMethod(id), setDefaultPaymentMethod(id)client-data/payment-methods…
wishlist(), wishlistAdd(productId), wishlistRemove(productId)client-data/wishlist…
notifications(opts), registerPushToken(input)client-data/notifications…
verificationStatus(), sendEmailVerification(), verifyEmail(input), sendPhoneVerification(input), verifyPhone(input)client-data/verification/… (the send ones send real email / SMS)
messages(opts), conversations(), sendMessage(input), setMessageStatus(id, status)client-data/messages…, client-data/conversationsThe signed-in customer's own messages and threads only (401 signed out). sendMessage writes to the business, from the customer, as plain text: it can't choose another recipient. setMessageStatus takes read, unread, archived or deleted (crm.service.ts)
benefits(), enrollBenefit(input), benefitEnrollments()client-data/benefits…

reservation: bookings (namespaces.ts:814-833)

MethodCallsReturns
definitions() ✓GET crm/reservations/definitions (open to visitors)booking-type records
availableSlots({ reservationDefinitionId, serviceName, serviceDate }) ✓POST client-data/reservations/available-slots{ slots: [...], … } or { slots: [], closed: true, error }
create(input) ✓ (intercepted)POST client-data/reservationsthe reservation; data.name = confirmation number. Sends emails and can text. Needs a signed-in customer (401 "Not signed in"); a visitor books through the site's booking page (POST crm/reservations/book)
list() ✓, get(id)GET client-data/reservations[/<id>]the customer's own
modify(input)PUT client-data/reservationsthe reservation
cancel(id)DELETE client-data/reservations/<id>nothing
guestLookup({ email, reservationNumber? }), guestCancel({ email, reservationNumber })GET crm/reservations/by-email/…, DELETE crm/reservations/cancel/…Without reservationNumber, only for the signed-in customer's own email or phone: anyone else gets 400 reservation_number_required

storefront: checkout, pricing, discounts, shipping (namespaces.ts:580-641)

MethodCalls
paymentGateways()GET storefront/payment-gateways
checkout(input), buyNow(input), checkoutMixed(input)POST storefront/checkout-cart, /checkout-buy-now, /checkout-mixed (the visitor's landing campaign is added as attribution)
takePayment(input)POST storefront/take-payment
subscriptions({ id? }), subscribe(input), subscription(id), updateSubscription({ id, action, … })storefront/subscriptions/…
stripeIntent(input), stripeCheckout(input), paypalConfig(), verifyPayment({ provider, configId, paymentId })storefront/stripe/intent, /stripe/checkout-session, /paypal/config, /verify-payment/… (a visitor gets { ok, status } only; staff get the gateway's record)
config()GET storefront/config (404 on the test engine)
pricing(input), cartPricing(input)POST storefront/pricing/calculate, /calculate-cart
taxCalculate(input), taxRate(input), taxExempt(input)POST storefront/tax/calculate, /rate, /check-exempt (the signed-in customer's own status only; signed out it answers exempt: false)
applyCoupon, validateCoupon, automaticDiscounts, calculateDiscounts, promotionPrice (input); activePromotions()storefront/discounts/apply, /validate, /automatic, /calculate, /promotions/price, GET …/promotions/active
shippingRates(input), shippingMethods(), shippingCost(input), shippingCalculate(input), verifyAddress(input)POST shipping/rates, GET shipping/methods, POST shipping/product-cost, /calculate, /verify-address
saveCart(input)POST storefront/cart/save
productSearch(input), productBrands(), productCategories(), productCollections()POST storefront/product/search, GET storefront/brands/, /categories, /collections/

Payments and the checkout pages: see Take payments on the site. Don't place a real order to test.

rental (namespaces.ts:646-670)

config() GET storefront/rental/config · catalog(opts) GET storefront/rental/items (rows) · get(sku) · availability({ sku, … }) GET …/items/<sku>/availability · quote({ sku, … }) POST …/items/<sku>/price · validateRestrictions({ sku, … }) · book(input) POST storefront/rental · list(opts) and getBooking(id): the signed-in customer's own rentals only (401 signed out; someone else's reads as 404) · cancel(id, reason) PUT storefront/rental/<id>/cancel: their own rental only · pay({ rentalId, … }) POST storefront/rental/<id>/payment records a payment, which is staff only (403 for a visitor or customer), so don't put it on a site page (rental.controller.ts). Not tested.

ticket, events (namespaces.ts:792-867)

  • ticket: list(opts) (rows), get(number), create(input) POST client-data/tickets, createWithAttachments({ attachments, … }), update(input), guestCreate({ email, … }) POST crm/tickets/email/create/<email>, guestLookup({ email, number? }), collections() (rows). Creating a ticket emails people; see Support ticket flow.
  • events: list(opts) GET client/events (rows), get(id), sessions(id), schedule(id), ticketTypes(id), participants(id), purchaseTickets(input), register(input), confirmOrder(input), myTickets(opts), getTicket(id), ticketQr(id), ticketPerks(id), transferTicket({ ticketId, … }), myParticipations(), respondToInvite({ participantId, … }), media(id), guestMedia({ eventId, guestId }), uploadMedia({ eventId, file, caption }), uploadGuestMedia(…), myBookings(). Not tested.

repository, page, site, files (namespaces.ts:869-1016)

MethodCallsReturns
repository.find({ datatype, query, options }) ✓POST repository/find/<datatype>rows (worked for a visitor on the test site)
repository.findOne({ datatype, id }), findAnyId({ datatype, id })GET repository/get/…, /find-any-id/… (matches username, email, slug, name, sku, number or id)one record
repository.findByAttribute({ datatype, attribute, value })GET repository/find-by-attribute/…
findRelated, search, searchAdvanced, aggregate, categories, schema, isUniquerepository/find-related, /search, /aggregate, /category, /collections, /isunique (positional arguments, so from page code only)
page.getBySlug(slug) ✓, page.getIndex(), page.getPageData({ datatype, dataId }), page.sitemap()site/page/<host>/<site>/<slug>, site/page-data/…, POST site/site-map
site.current() ✓, site.org(domain?), site.company(domain?), site.bySiteName(…), site.hostMappings(name?), site.appDsl(name)site/get-site-by-hostname/<host>, site/get-org/…, site/company/…, …
files.list, upload({ file, path }), delete, signedUrl, myFiles(), uploadCustomerFile({ file, category }), deleteCustomerFile(path)repository/customer/file/…, repository/file/signurl, client-data/files…

Repository reads only; visitors see what the collection's access rules allow. Writing records from a page goes through a CRM form: see Put a form on a website page.

ui, state, form, media, navigate (no server)

MethodDoes
ui.drawer(id, action?) / ui.drawer({ id, action }) ✓open / close / toggle (default) on the wm-drawer with that id; no such element → the site's sidebar
ui.dialog(…) ✓, ui.modal(…)the same for wm-dialog; no such element → the storefront quick view
ui.nav(…), ui.search(action)wm-navigation / wm-mega-menu, or the UI store's navOpen / searchOpen
ui.scrollTo(selector, { behavior, block })smooth scroll
ui.notify(message) ✓adds to the UI store's notices; nothing shows on a site page (tested)
ui.confirm(message)window.confirm, as a promise
state.get(path), state.set(path, value) / set({ path, value }), inc, dec, toggle, subscribe(path, fn) ✓the page state the bindings read
form.serialize(form), form.validate(form, rules), form.reset(form)read, check and reset a form element; they don't send it
media.play, pause, stop, volume, seek, next, prev, toggleMute, loadTrack({ url, title, cover }), isPlaying, animate({ target, action })<audio>/<video> on the page or the site's bottom player
navigate(url, params?) ✓window.location.href, with params added as a query string
blocknote.register(type, fn), unregister, list, render(json), renderMaybe(text)BlockNote rendering for data-bind-html
manifest()the runtime's own list of attributes and methods (some examples in it are wrong; see the warnings above)
_dispatch('ns.method', payload)what data-action calls; the only way to reach activity, api and contentPlayer from code

Reachable only through data-action or _dispatch

window.appmint.activity, .api and .contentPlayer are undefined (tested; index.ts:134-159), but the dispatcher has them (index.ts:90-111):

  • activity.<view|share|like|unlike|dislike|bookmark|follow|favorite|unfavorite|summary|stats|byResource|mine> with payload { "datatype": "…", "id": "<sk>" } → stats/<datatype>/<id>/…. Toggles with no action read your state and flip it. rate and react need a second value and can't be reached this way. Only view and share work for a visitor who isn't signed in.
  • api.get / api.del with a path string: data-action="api.get" data-payload='"storefront/config"' (tested: it reached /api/storefront/config). api.post / put / patch can't take a body this way; from page code use fetch('/api/<path>', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }).
  • contentPlayer.<mine|outline|enroll|item|complete|progress|answer|saveDraft|applyBenefit> for Content Studio posts (client/content-studio/…).

An unknown namespace or method fails with appmint: unknown namespace "x" / appmint: x.y is not a method, which data-on-error="set-state:err={{message}}" can show.

When it's done

Tell the person: the page's URL; each live part and what it reads or does; where its code is (the page's Page settings, root's, or which Scripts file and on which pages it's linked); what you tested (signed out and signed in, one click each); and anything you didn't submit because it would email or text real people. Open the page on the site and in Build Studio.

What it can't do yet

  • Toasts from the runtime. notify: effects and ui.notify show nothing on a site page. Use set-state and data-show.
  • Telling a visitor from a signed-in customer with customer.signedIn / isSignedIn(). Both are always true. Use customer.current.data.email.
  • Sign-in and sign-up from page code. customer.signIn / signUp fail for a visitor who isn't signed in. Link to the site's /login, or use a registration form (Registration that creates the customer).
  • Page JavaScript after an in-app move. It runs only on a full load; design the page so attributes carry what must work after /login.
  • Ordered script files. scriptLinks load async; there is no "run after" setting.
  • The Scripts record doing anything on its own. It isn't read by the site; its Status is ignored.
  • hover triggers, {{$result.x}}, bare refresh, data-loop over plain strings, open-dialog. All tested broken; the workarounds are in the warnings above.
  • activity.rate / react and api.post from attributes. One payload only; use fetch('/api/…').
  • Clear error messages. Errors usually say "Request failed with status code …"; read err.status and err.details.
  • data-bind-html sanitizing. Bind it only to text the business wrote.

Don'ts

  • Don't put <script> in the page HTML or in root's screens; use Page settings → Style or a scriptLinks file.
  • Don't start page code without the design-mode guard and a wait for your element and window.appmint.
  • Don't use a fixed setTimeout(…, 2000) to "wait for the runtime".
  • Don't call Number(), Math, JSON or Date in a binding; use data-format or a method on the value.
  • Don't write {{this.x}}; write {{x}}. Don't put text into JSON as "{{data.name}}"; use {{$json:data.name}}.
  • Don't put data-action on links, checkboxes with data-trigger="click", or anything whose clicks your own code handles.
  • Don't rely on customer.signedIn, notify: or a bare refresh.
  • Don't append a second copy of a script with page_write; replace it with replaceStyle: true and the whole script.
  • Don't submit a real booking, ticket, sign-up or order to test.
  • Don't change the root page's script without checking it parses; it runs on every page.