> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getaptly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Building Embedded Apps & Dashboards

> Add the Aptly SDK to a custom dashboard or embedded app to get delegate auth, user identity, and config variables — then read and write Aptly data directly from the page.

> Add the Aptly SDK to a custom dashboard, internal tool, or any iframe to get delegate
> auth, user identity, and config variables -- then read and write Aptly data directly
> from the page. This guide is written for developers and for AI agents generating
> apps on a user's behalf.

Related documentation:

* [SDK Reference](/aptly-sdk-reference) -- full `window.aptly` API, config scoping, URL params
* [Delegate Tokens](/delegate-tokens) -- raw token exchange API for server-side integrations
* [Embed App Actions](/embed-actions) -- trigger Aptly UI interactions from an embedded app
* [Field Types Reference](/field-types) -- value formats for reading and writing card data
* [Rate Limits](/rate-limits) -- request limits and 429 handling
* [Documentation index](/llms.txt) -- machine-readable index of all pages

***

## How embedded apps work

When your page runs inside Aptly (as a board tab or dashboard panel), Aptly delivers a
short-lived delegate token and context -- the logged-in user, their organization, and
admin-declared config -- to your page via postMessage. The SDK handles that handshake
and exposes everything on `window.aptly`. Your page then calls the Aptly REST API
directly with the token.

Three architectural rules apply to every embedded app:

1. **The page calls the Aptly API at runtime.** Do not call AI model APIs (Anthropic,
   OpenAI, or any other) from the page. Browser-side model calls require credentials
   that are only injected inside the model vendor's own products; on a hosted page they
   fail with authentication and CORS errors. If an AI agent generates the page, the
   agent's involvement ends at generation time.
2. **Fetch data fresh on every load.** Never embed a snapshot of queried data in the
   page source. A snapshot renders correctly but is frozen at generation time, and the
   discrepancy is invisible to users until the numbers drift.
3. **Compute deterministically in the page.** Filtering, aggregation, and report logic
   belong in plain JavaScript, not in a runtime model call. Client-side compute is
   instant, free, and produces the same result every time.

***

## Quick start

Add one script tag to your HTML `<head>`:

```html theme={null}
<script src="https://app.getaptly.com/api/ai/app-builder/sdk.js"></script>
```

The SDK is served from `app.getaptly.com` only. After it loads, `window.aptly` is
available:

```js theme={null}
// Auth-aware fetch: adds the token, prepends the API base URL
// (https://core-api.getaptly.com), and retries once on 401 with a fresh token.
const res = await aptly.fetch("/board/MY_BOARD_ID?page=0&pageSize=1000");
const body = await res.json();

// Context delivered by Aptly
aptly.org.id; // companyId of the logged-in user's org
aptly.org.name; // org display name
aptly.user.email; // logged-in user's email
aptly.user.firstName;
aptly.config; // admin-declared config variables
aptly.token; // current delegate token (managed by the SDK)
```

Detect SDK readiness by polling for `window.aptly` with a bounded attempt count.
Do not attach an `onerror` handler to the script tag for this purpose: some sandboxed
environments fire `onerror` even when the script subsequently loads, which produces
false-positive failures.

```js theme={null}
function whenSdkReady(timeoutMs) {
  return new Promise(function (resolve, reject) {
    var maxAttempts = Math.ceil(timeoutMs / 100);
    var attempts = 0;
    (function check() {
      if (window.aptly) return resolve(window.aptly);
      attempts += 1;
      if (attempts >= maxAttempts) {
        return reject(
          new Error(
            "The Aptly SDK did not load after " + attempts + " attempts."
          )
        );
      }
      setTimeout(check, 100);
    })();
  });
}
```

***

## Enable the SDK for your app

Open the board view settings for the tab that contains your app (or the dashboard
panel) and toggle on **Needs Aptly Context**. This tells Aptly to deliver a delegate
token and context to your app on each load. Without it, the postMessage handshake never
completes and the page receives no token.

***

## Requesting the delegate token

On boot, follow this order:

1. If `aptly.token` is already populated, initialize immediately.
2. Otherwise call `aptly.requestToken({ version: 2 })` and handle the result.
3. Fall back to a bounded poll for `aptly.token` only when running against an SDK build
   that does not expose `requestToken`.

`requestToken` **never rejects**. The default (v1) form resolves the token string, or
`null` on failure, with no further detail. The version-2 form resolves a result object:

```js theme={null}
const { token, error } = await aptly.requestToken({ version: 2 });
// success: { token: '...', error: null }
// failure: { token: null,  error: { code, message, retryAfterMs } }
```

Handle failures immediately rather than waiting for a timeout:

* `RATE_LIMITED` -- retry once after `error.retryAfterMs` milliseconds.
* Any other code -- display `error.message` and `error.code` to the user with
  instructions (typically: enable **Needs Aptly Context** and confirm the app is
  authorized).
* A `null` token from a v1-only SDK build -- treat as a failure with the same guidance.

Wrap the request in a hard timeout (8 seconds is a reasonable default) so an unanswered
postMessage cannot stall the page indefinitely. The complete worked example below shows
the full boot sequence.

***

## Reading board data

### Resolve fields from the schema -- never hardcode field IDs

Fetch `GET /api/schema/{boardId}` before reading or writing card data, and resolve the
fields your app needs at runtime. Match candidates against both the field `label` and
the field `key`, case-insensitively, with a list of accepted variants per field:

```js theme={null}
var F = resolveFields(schema, {
  stage: ["Stage"],
  marketRent: ["Market Rent", "marketRent"]
});
```

Built-in fields use semantic keys (`name`, `stage`, `dueAt`); custom fields use
generated IDs; some boards expose camelCase keys with different display labels.
Matching both label and key handles every case and makes the app portable across
boards and companies. When a required field is missing, surface an error that lists the
fields the board actually has -- this turns a configuration mismatch into something the
user can fix without developer help.

### Paginate with the response envelope

`GET /api/board/{boardId}?page=N&pageSize=M` returns:

```json theme={null}
{ "data": [ ...cards ], "count": 412, "page": 0, "pageSize": 1000 }
```

`count` is the board total. Use it for an exact stop condition and progress display.
Request `pageSize=1000` (the maximum) so most boards load in one or two requests.

### Respect the rate limit

The API allows **60 requests per minute per connection**. For multi-page loads:

* pause about 1 second between page requests,
* on a `429` response, retry once after the `Retry-After` header (or 5 seconds if the
  header is absent), and show a "retrying" message in the loading state,
* cap every pagination loop at a hard maximum page count.

### Normalize field value formats

Value formats differ between reading and writing, and across boards:

* **Money** fields are returned as `{ amount, currency }` from list endpoints but are
  written as plain numbers. Accept both when reading.
* **Checkbox** values may arrive as `true`, `"true"`, `"Yes"`, or `1`.
* **Dates** may be ISO (`2026-05-15`) or `MM-DD-YYYY`. Parse both.
* Treat unrecognized field types as plain strings. The
  [Field Types Reference](/field-types) lists the write formats.

### API error reference

All API calls use the `Authorization: DelegateToken <token>` scheme -- not `Bearer` --
against `https://core-api.getaptly.com` (`aptly.fetch` sets both automatically).

| Situation                                       | Response                                                |
| ----------------------------------------------- | ------------------------------------------------------- |
| Board exists but API access is disabled         | `400` with `{ "error": { "code": "API_DISABLED" } }`    |
| Board does not exist                            | `404` with `{ "error": { "code": "BOARD_NOT_FOUND" } }` |
| `Bearer` scheme used instead of `DelegateToken` | `401` `"Invalid or missing API key"`                    |
| Token lacks the required scope                  | `403 Forbidden`                                         |
| Token expired or invalid                        | `401`                                                   |
| Rate limit exceeded                             | `429`, may include a `Retry-After` header               |

Notes: API access is enabled per board in the board's settings, and
`GET /api/boards` returns only boards that have it enabled.

***

## Building a reliable UI

Embedded apps are used by people who will not open browser developer tools. Build
accordingly:

**Every error renders on screen.** Each failure path -- a missing field, an expired
token, a rate limit, an unexpected exception -- must appear in a visible error element
with instructions for resolving it. Register global `error` and `unhandledrejection`
handlers so unanticipated exceptions also land in the UI. Do not rely on `console.*`
output for anything a user might need to see.

**Every loop is bounded.** Polls and pagination loops need a maximum attempt count, and
the timeout message should state it. Unbounded loops hang the page or hammer the API
when something unexpected happens.

**Follow the hosted-page source requirements.** Pages uploaded to Aptly's page hosting
pass through an HTML transform. The following constraints keep source intact through
that transform (and are good practice everywhere):

* Attach all event handlers with `addEventListener` in script blocks. Inline handler
  attributes (`onclick=`, `onchange=`, and similar) are not preserved.
* Build DOM with `createElement` and `textContent`, not concatenated HTML strings
  assigned to `innerHTML`. This also avoids HTML-injection issues.
* Do not place a `<` character directly followed by a letter anywhere outside a quoted
  string -- including inside comments (for example, write `returns the total` rather
  than `returns <total>`). Such sequences can be interpreted as markup.
* Keep source pure ASCII. Replace em dashes, ellipses, smart quotes, and arrows with
  ASCII equivalents, or use `\uXXXX` escapes inside string literals.
* Keep lines under 200 characters.

**Skip the refresh button by default.** Pages fetch live data on every load and
embedded tabs reload when opened. Add a manual refresh control only when users keep the
tab open for extended periods.

***

## Config variables

Config variables let users configure your app with friendly pickers -- board selectors,
text fields, toggles -- rather than pasting raw IDs. Admins declare them in the app
store admin panel; users fill them in during install. Read them from `aptly.config`
with a sensible fallback:

```js theme={null}
const boardId = aptly.config.BOARD_ID ?? "my-default-board";
const threshold = aptly.config.OVERDUE_THRESHOLD ?? 30;
```

`aptly.config` is the fully merged configuration for the current context. If the app
supports board-level or user-level config, Aptly merges scopes before delivering
context (priority: user over board over company) -- your code always reads one flat
object. See [Config scoping](/aptly-sdk-reference#config-scoping).

***

## Testing your app

Embedded context only exists inside Aptly, so local development uses one of three
emulation options.

### Option A -- `startEmulation` with a dev token (recommended)

An Aptly admin generates a long-lived developer token in **Global Admin -> \[Company] ->
Dev Tokens**. Call `startEmulation` after the SDK loads:

```js theme={null}
await aptly.startEmulation("DEV_TOKEN", { config: { BOARD_ID: "my-board" } });
```

`startEmulation` verifies the token against the Aptly API to resolve real user and org
identity, then populates `window.aptly` exactly as if Aptly had delivered the context.
Two behaviors to account for:

* `startEmulation` **resolves even when verification fails**; a failed verify leaves
  `aptly.user` unpopulated. After it resolves, check `aptly.user && aptly.user.id` and
  treat an unpopulated identity as an invalid token.
* When the page runs genuinely embedded in Aptly, real postMessage context takes
  priority over emulation.

**Do not hardcode dev tokens in page source.** Dev tokens are live credentials. The
recommended pattern is a "Developer mode" toggle that expands into a paste field: the
operator pastes a token at runtime, the app verifies it via `startEmulation`, and on
success stores it in `localStorage` so dev mode survives reloads (cleared when the
toggle is turned off). Persist this state in `localStorage`, not the URL -- when
embedded, the iframe URL belongs to Aptly's tab configuration. A page built this way
ships zero secrets and the toggle can remain in any build.

### Option B -- `window.APTLY_DEV` block

Set values directly before the SDK tag -- useful for mocking config without a token.
Remove before deploy:

```html theme={null}
<script>
  window.APTLY_DEV = {
    token: "YOUR_API_KEY",
    config: { BOARD_ID: "my-board" },
    org: { id: "your-company-id", name: "Acme" },
    user: { email: "you@example.com", firstName: "Dev" }
  };
</script>
<script src="https://app.getaptly.com/api/ai/app-builder/sdk.js"></script>
```

### Option C -- URL params

No code changes needed; nothing to remove before deploy:

```
https://your-app.example.com?aptly_token=MY_TOKEN&aptly_config_BOARD_ID=my-board
```

See the [SDK Reference](/aptly-sdk-reference#url-params) for the full parameter list.

### Where testing works -- and where it does not

Test in a regular browser tab (with one of the options above) or embedded in Aptly with
**Needs Aptly Context** enabled. AI assistant sandboxes (for example, the Claude.ai
artifact preview) enforce a Content Security Policy that blocks the SDK script from
loading; iframes inside those sandboxes inherit the same policy. Pages that depend on
the Aptly SDK cannot be previewed there -- open the file in a normal browser tab
instead.

***

## Triggering Aptly UI from your app

The SDK exposes named methods that open dialogs in the parent Aptly window -- the phone
dialer, email composer, card creation, contact navigation, and more. Each returns a
`Promise` of `{ success, error? }` and is a no-op outside Aptly. Actions must be
enabled per embed by an admin:

```js theme={null}
await aptly.startDialer({ number: "+15551234567", name: "Jane Smith" });
await aptly.openEmailComposer({
  to: ["jane@example.com"],
  subject: "Lease renewal"
});
await aptly.createCard({
  boardId: "my-board-uuid",
  fields: { name: "Follow-up call" }
});
console.log(aptly.actions); // actions granted to this embed
```

See [Embed App Actions](/embed-actions) for the full list, payloads, and setup.

***

## Complete worked example: board overview

The page below runs on any board with no custom-field requirements -- it uses only the
built-in `stage` field and card basics. The top section is a wrapping grid of boxes
with the card count per stage; selecting a box filters the table below to that stage
(selecting it again, or "All cards", clears the filter). Each table row demonstrates
embed actions: clicking the row opens the card in the board side pane, the expand icon
opens the card in a fullscreen view, the email icon looks up the card's
linked contacts via the REST API and opens the composer with their email addresses as
recipients (`to`) and the card name as subject, and the phone icon opens the dialer
with the first linked phone number found. Action failures (not embedded,
not granted, timeout) render as on-screen messages. Every pattern in this guide is
applied: the boot sequence with version-2 token requests, schema-first field
resolution, rate-limited pagination, on-screen errors, transform-safe source, and
admin-granted UI actions. Use it as a starting template and extend it with
board-specific fields via `resolveFields`.

```html theme={null}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Board Overview</title>
    <script src="https://app.getaptly.com/api/ai/app-builder/sdk.js"></script>
    <style>
      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }
      body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        background: #f8f7f4;
        color: #1a1a18;
        font-size: 14px;
      }
      .topbar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 14px 20px;
        background: #fff;
        border-bottom: 0.5px solid rgba(0, 0, 0, 0.1);
      }
      .topbar h1 {
        font-size: 15px;
        font-weight: 500;
      }
      .timestamp {
        font-size: 12px;
        color: #888;
      }
      .content {
        padding: 20px;
      }
      .error-banner {
        background: #fcebeb;
        color: #a32d2d;
        padding: 10px 14px;
        border-radius: 8px;
        font-size: 13px;
        margin-bottom: 16px;
        display: none;
        white-space: pre-line;
      }
      .loading-state {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 12px;
        padding: 60px 20px;
        color: #888;
        font-size: 13px;
      }
      .loading-state .spinner {
        width: 22px;
        height: 22px;
        border: 2px solid rgba(0, 0, 0, 0.1);
        border-top-color: #1a1a18;
        border-radius: 50%;
        animation: spin 0.8s linear infinite;
      }
      @keyframes spin {
        to {
          transform: rotate(360deg);
        }
      }
      .stage-grid {
        display: flex;
        flex-wrap: wrap;
        gap: 10px;
        margin-bottom: 20px;
      }
      .stage-box {
        min-width: 130px;
        flex: 0 1 auto;
        background: #fff;
        border: 0.5px solid rgba(0, 0, 0, 0.1);
        border-radius: 10px;
        padding: 14px 16px;
        cursor: pointer;
        text-align: left;
        transition:
          border-color 0.15s,
          background 0.15s;
      }
      .stage-box:hover {
        background: #f0efe9;
      }
      .stage-box.active {
        border-color: #1a1a18;
        border-width: 1.5px;
        background: #f0efe9;
      }
      .stage-box .count {
        font-size: 22px;
        font-weight: 500;
      }
      .stage-box .label {
        font-size: 11px;
        color: #888;
        margin-top: 2px;
        text-transform: uppercase;
        letter-spacing: 0.04em;
      }
      .card-table {
        width: 100%;
        font-size: 13px;
        border-collapse: collapse;
        background: #fff;
        border-radius: 8px;
        overflow: hidden;
        border: 0.5px solid rgba(0, 0, 0, 0.08);
      }
      .card-table th {
        text-align: left;
        font-size: 11px;
        font-weight: 500;
        color: #888;
        padding: 8px 12px;
        border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
        text-transform: uppercase;
        letter-spacing: 0.03em;
      }
      .card-table td {
        padding: 8px 12px;
        border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
      }
      .card-table tr:last-child td {
        border-bottom: none;
      }
      .card-table tr:hover td {
        background: #f8f7f4;
      }
      .stage-pill {
        display: inline-block;
        font-size: 11px;
        padding: 2px 8px;
        border-radius: 6px;
        font-weight: 500;
        background: #e6f1fb;
        color: #185fa5;
      }
      .card-row {
        cursor: pointer;
      }
      .action-cell {
        white-space: nowrap;
        text-align: right;
      }
      .action-btn {
        border: none;
        background: none;
        cursor: pointer;
        font-size: 14px;
        padding: 4px 6px;
        border-radius: 6px;
        color: #888;
        line-height: 1;
      }
      .action-btn:hover {
        background: #f0efe9;
        color: #1a1a18;
      }
      .filter-note {
        font-size: 12px;
        color: #666;
        margin-bottom: 10px;
      }
    </style>
  </head>
  <body>
    <div class="topbar">
      <h1>Board Overview</h1>
      <span class="timestamp" id="timestamp"></span>
    </div>
    <div class="content">
      <div class="error-banner" id="errorBanner"></div>
      <div class="loading-state" id="loadingState">
        <div class="spinner"></div>
        <span id="loadingText">Connecting to Aptly...</span>
      </div>
      <div id="dashboard" style="display:none">
        <div class="stage-grid" id="stageGrid"></div>
        <div class="filter-note" id="filterNote"></div>
        <table class="card-table">
          <thead>
            <tr>
              <th>Card</th>
              <th>Stage</th>
              <th>Last updated</th>
              <th></th>
            </tr>
          </thead>
          <tbody id="cardRows"></tbody>
        </table>
      </div>
    </div>

    <script>
      "use strict";

      /* -- Configuration ----------------------------------------------- */

      function boardId() {
        return (
          (window.aptly && aptly.config && aptly.config.BOARD_ID) ||
          "YOUR_DEFAULT_BOARD"
        );
      }

      /* -- Error surface: all errors render on screen, never console-only -- */

      function showError(msg) {
        var eb = document.getElementById("errorBanner");
        eb.textContent = msg;
        eb.style.display = "block";
        document.getElementById("loadingState").style.display = "none";
      }

      window.addEventListener("error", function (ev) {
        showError("Unexpected error: " + (ev.message || "unknown"));
      });
      window.addEventListener("unhandledrejection", function (ev) {
        var r = ev.reason;
        showError(
          "Unexpected error: " + (r && r.message ? r.message : String(r))
        );
      });

      function setLoading(on, text) {
        document.getElementById("loadingState").style.display = on
          ? "flex"
          : "none";
        if (text) document.getElementById("loadingText").textContent = text;
        if (!on) document.getElementById("dashboard").style.display = "";
      }

      /* -- SDK readiness, polled with a bounded attempt count --------------- */

      function whenSdkReady(timeoutMs) {
        return new Promise(function (resolve, reject) {
          var maxAttempts = Math.ceil(timeoutMs / 100);
          var attempts = 0;
          (function check() {
            if (window.aptly) return resolve(window.aptly);
            attempts += 1;
            if (attempts >= maxAttempts) {
              return reject(
                new Error(
                  "The Aptly SDK did not load after " +
                    attempts +
                    " attempts. " +
                    "This page must be hosted inside Aptly as a board tab; " +
                    "for local testing open it in a regular browser tab with dev emulation."
                )
              );
            }
            setTimeout(check, 100);
          })();
        });
      }

      function withTimeout(promise, ms, doing) {
        return new Promise(function (resolve, reject) {
          var timer = setTimeout(function () {
            reject(
              new Error("Timed out after " + ms / 1000 + "s while " + doing)
            );
          }, ms);
          promise.then(
            function (v) {
              clearTimeout(timer);
              resolve(v);
            },
            function (e) {
              clearTimeout(timer);
              reject(e);
            }
          );
        });
      }

      function delay(ms) {
        return new Promise(function (resolve) {
          setTimeout(resolve, ms);
        });
      }

      /* -- Schema-first field resolution: labels and keys, never hardcoded -- */

      async function fetchSchema(bid) {
        var res = await aptly.fetch("/schema/" + encodeURIComponent(bid));
        if (!res.ok)
          throw await httpError(
            res,
            'loading the schema for board "' + bid + '"',
            bid
          );
        return res.json();
      }

      function resolveFields(schema, wanted) {
        var byLabel = {},
          byKey = {};
        schema.forEach(function (f) {
          if (!f) return;
          if (f.label) byLabel[f.label.trim().toLowerCase()] = f;
          if (f.key) byKey[String(f.key).trim().toLowerCase()] = f;
        });
        var map = {},
          missing = [];
        Object.keys(wanted).forEach(function (name) {
          var match = null;
          wanted[name].some(function (candidate) {
            var c = candidate.toLowerCase();
            match = byLabel[c] || byKey[c] || null;
            return !!match;
          });
          if (match) map[name] = match.key;
          else missing.push(wanted[name][0]);
        });
        if (missing.length) {
          var available = schema
            .map(function (f) {
              return f && f.label;
            })
            .filter(Boolean)
            .sort()
            .join(", ");
          throw new Error(
            "This board is missing fields the dashboard needs: " +
              missing.join(", ") +
              ". " +
              "Fields found on this board: " +
              (available || "(none)")
          );
        }
        return map;
      }

      /* -- Pagination with rate limiting, 429 retry, and a page cap --------- */

      async function fetchAllCards(bid, onProgress) {
        var PAGE_SIZE = 1000;
        var MAX_PAGES = 60;
        var INTER_PAGE_MS = 1000;
        var all = [],
          total = null;

        for (var page = 0; page < MAX_PAGES; page++) {
          if (page > 0) await delay(INTER_PAGE_MS);

          var url =
            "/board/" +
            encodeURIComponent(bid) +
            "?page=" +
            page +
            "&pageSize=" +
            PAGE_SIZE;
          var res = await aptly.fetch(url);

          if (res.status === 429) {
            var retryAfter = parseInt(
              res.headers && res.headers.get && res.headers.get("Retry-After"),
              10
            );
            var waitMs = (isNaN(retryAfter) ? 5 : retryAfter) * 1000;
            if (onProgress)
              onProgress(
                all.length,
                total,
                "Rate limited -- retrying in " +
                  Math.ceil(waitMs / 1000) +
                  "s..."
              );
            await delay(waitMs);
            res = await aptly.fetch(url);
          }

          if (!res.ok)
            throw await httpError(
              res,
              'loading cards from board "' + bid + '"',
              bid
            );
          var body = await res.json();
          var cards = body.data || [];
          if (total == null) total = body.count != null ? body.count : null;
          all = all.concat(cards);
          if (onProgress) onProgress(all.length, total);
          if (
            (total != null && all.length >= total) ||
            cards.length < PAGE_SIZE
          )
            break;
        }
        return all;
      }

      /* -- Translate API error codes into instructions users can act on ----- */

      async function httpError(res, doing, bid) {
        var code = "",
          apiMsg = "";
        try {
          var b = await res.json();
          code = (b.error && b.error.code) || "";
          apiMsg = (b.error && b.error.message) || "";
        } catch (e) {}
        if (code === "API_DISABLED")
          return new Error(
            'API access is not enabled for board "' +
              bid +
              '". An admin can enable it in the board settings, then refresh.'
          );
        if (code === "BOARD_NOT_FOUND" || res.status === 404)
          return new Error(
            'Board "' +
              bid +
              '" was not found while ' +
              doing +
              ". Set the BOARD_ID config variable to the correct board UUID."
          );
        if (res.status === 401)
          return new Error(
            "Your Aptly token expired or is invalid while " +
              doing +
              ". Reload the page to get a fresh token."
          );
        if (res.status === 403)
          return new Error(
            "The delegate token does not have read access to this board. An admin needs to grant the boards scope for this app."
          );
        return new Error(
          "Aptly API returned " +
            res.status +
            (apiMsg ? " (" + apiMsg + ")" : "") +
            " while " +
            doing +
            "."
        );
      }

      /* -- Aggregation: pure, testable --------------------------------- */

      function countByStage(cards, stageKey) {
        /* Returns [{stage, count}] sorted by count descending; cards with
     no stage value group under "(no stage)". */
        var counts = {};
        cards.forEach(function (c) {
          var s = c[stageKey];
          var label =
            s == null || String(s).trim() === "" ? "(no stage)" : String(s);
          counts[label] = (counts[label] || 0) + 1;
        });
        return Object.keys(counts)
          .map(function (k) {
            return { stage: k, count: counts[k] };
          })
          .sort(function (a, b) {
            return b.count - a.count || a.stage.localeCompare(b.stage);
          });
      }

      function cardsForStage(cards, stageKey, stage) {
        if (stage == null) return cards;
        return cards.filter(function (c) {
          var s = c[stageKey];
          var label =
            s == null || String(s).trim() === "" ? "(no stage)" : String(s);
          return label === stage;
        });
      }

      /* -- Embed actions: open card pane/view, composer, dialer ------------- */
      /* Named SDK methods post to the Aptly parent window and resolve
   { success, error? }. They require admin-granted action permissions
   per embed and are no-ops outside Aptly. Failures render on screen. */

      function cardId(c) {
        return c._id || c.id || c.uuid || null;
      }

      function actionFailureMessage(error, label) {
        if (error === "not-in-iframe") {
          return (
            'UI actions like "' +
            label +
            '" only work when this page is embedded in Aptly.'
          );
        }
        if (error === "timeout") {
          return (
            'Aptly did not respond to "' +
            label +
            '". The action may not be enabled ' +
            "for this embed -- an admin can grant it in the embed settings."
          );
        }
        return (
          'The "' + label + '" action failed: ' + (error || "unknown error")
        );
      }

      function runAction(promise, label) {
        promise
          .then(function (result) {
            if (!result || !result.success) {
              showError(actionFailureMessage(result && result.error, label));
            }
          })
          .catch(function (e) {
            showError(
              'The "' +
                label +
                '" action failed: ' +
                (e && e.message ? e.message : String(e))
            );
          });
      }

      function openPane(c) {
        var id = cardId(c);
        if (!id) {
          showError("This card has no id, so it cannot be opened.");
          return;
        }
        runAction(aptly.openCardPane({ cardId: id }), "open card");
      }

      function openPopout(c) {
        var id = cardId(c);
        if (!id) {
          showError("This card has no id, so it cannot be opened.");
          return;
        }
        runAction(aptly.openCardView({ cardId: id }), "expand card");
      }

      async function fetchCardContacts(c) {
        /* Linked person contacts for a card; used to resolve email recipients
     and phone numbers, which the card record does not carry directly. */
        var id = cardId(c);
        if (!id)
          throw new Error(
            "This card has no id, so its contacts cannot be looked up."
          );
        var res = await aptly.fetch(
          "/board/" +
            encodeURIComponent(boardId()) +
            "/" +
            encodeURIComponent(id) +
            "/contacts"
        );
        if (!res.ok)
          throw await httpError(
            res,
            "loading contacts for this card",
            boardId()
          );
        var body = await res.json();
        return Array.isArray(body) ? body : body.data || [];
      }

      /* Contact phone numbers live at contact.phone: an array of
   { number: '+16617558531', type: 'mobile' } objects, usually with
   multiple entries. Normalize entries to the bare number string the
   dialer expects; email entries are normalized the same way. */
      function phoneNumberOf(entry) {
        if (!entry) return null;
        if (typeof entry === "string") return entry;
        if (typeof entry === "object" && typeof entry.number === "string")
          return entry.number;
        return null;
      }

      function emailAddressOf(entry) {
        if (!entry) return null;
        if (typeof entry === "string") return entry;
        if (typeof entry === "object") {
          if (typeof entry.address === "string") return entry.address;
          if (typeof entry.email === "string") return entry.email;
        }
        return null;
      }

      function contactPhone(p) {
        if (Array.isArray(p.phone)) {
          for (var i = 0; i < p.phone.length; i++) {
            var n = phoneNumberOf(p.phone[i]);
            if (n) return n;
          }
          return null;
        }
        return phoneNumberOf(p.phone);
      }

      function contactEmail(p) {
        if (Array.isArray(p.email)) {
          for (var i = 0; i < p.email.length; i++) {
            var e = emailAddressOf(p.email[i]);
            if (e) return e;
          }
          return null;
        }
        return emailAddressOf(p.email);
      }

      async function openComposer(c) {
        /* The composer accepts recipients as a `to` list of email addresses.
     Resolve them from the card's linked contacts; open with just the
     subject when no linked contact has an email. */
        try {
          var contacts = await fetchCardContacts(c);
          var to = [];
          contacts.forEach(function (p) {
            var email = contactEmail(p);
            if (email) to.push(email);
          });
          var payload = { subject: c.name || "" };
          if (to.length) payload.to = to;
          runAction(aptly.openEmailComposer(payload), "compose email");
        } catch (e) {
          showError(e && e.message ? e.message : String(e));
        }
      }

      async function dialCard(c) {
        /* The dialer needs an E.164 number string; resolve it from linked contacts. */
        try {
          var contacts = await fetchCardContacts(c);
          var found = null;
          contacts.some(function (p) {
            var number = contactPhone(p);
            if (number) {
              found = { number: number, name: p.name || "" };
              return true;
            }
            return false;
          });
          if (!found) {
            showError(
              'No linked contact with a phone number was found on "' +
                (c.name || "this card") +
                '".'
            );
            return;
          }
          runAction(aptly.startDialer(found), "start call");
        } catch (e) {
          showError(e && e.message ? e.message : String(e));
        }
      }

      /* -- Rendering: createElement and textContent only, no HTML strings --- */

      var STATE = { cards: [], stageKey: "stage", activeStage: null };

      function fmtDate(iso) {
        if (!iso) return "-";
        var d = new Date(iso);
        if (isNaN(d)) return "-";
        return d.toLocaleDateString(undefined, {
          year: "numeric",
          month: "short",
          day: "numeric"
        });
      }

      function renderStageBoxes() {
        var grid = document.getElementById("stageGrid");
        grid.textContent = "";

        function makeBox(label, count, stageValue) {
          var box = document.createElement("button");
          box.className =
            "stage-box" + (STATE.activeStage === stageValue ? " active" : "");
          var countEl = document.createElement("div");
          countEl.className = "count";
          countEl.textContent = count;
          var labelEl = document.createElement("div");
          labelEl.className = "label";
          labelEl.textContent = label;
          box.appendChild(countEl);
          box.appendChild(labelEl);
          box.addEventListener("click", function () {
            /* Click toggles: same stage again (or All) clears the filter. */
            STATE.activeStage =
              STATE.activeStage === stageValue ? null : stageValue;
            renderStageBoxes();
            renderTable();
          });
          grid.appendChild(box);
        }

        makeBox("All cards", STATE.cards.length, null);
        countByStage(STATE.cards, STATE.stageKey).forEach(function (entry) {
          makeBox(entry.stage, entry.count, entry.stage);
        });
      }

      function renderTable() {
        var tbody = document.getElementById("cardRows");
        tbody.textContent = "";

        var note = document.getElementById("filterNote");
        note.textContent =
          STATE.activeStage == null
            ? "Showing all " + STATE.cards.length + " cards"
            : "Showing cards in stage: " +
              STATE.activeStage +
              " (click the box again to clear)";

        var rows = cardsForStage(
          STATE.cards,
          STATE.stageKey,
          STATE.activeStage
        );

        if (rows.length === 0) {
          var tr = document.createElement("tr");
          var td = document.createElement("td");
          td.colSpan = 4;
          td.textContent =
            STATE.activeStage == null
              ? "This board has no cards."
              : "No cards in this stage.";
          td.style.color = "#888";
          td.style.textAlign = "center";
          td.style.padding = "16px";
          tr.appendChild(td);
          tbody.appendChild(tr);
          return;
        }

        rows.forEach(function (c) {
          var tr = document.createElement("tr");
          tr.className = "card-row";
          /* Clicking the row opens the card in the board side pane. */
          tr.addEventListener("click", function () {
            openPane(c);
          });

          var tdName = document.createElement("td");
          tdName.textContent = c.name || "(unnamed card)";
          tdName.style.fontWeight = "500";
          tr.appendChild(tdName);

          var tdStage = document.createElement("td");
          var pill = document.createElement("span");
          pill.className = "stage-pill";
          var s = c[STATE.stageKey];
          pill.textContent =
            s == null || String(s).trim() === "" ? "(no stage)" : String(s);
          tdStage.appendChild(pill);
          tr.appendChild(tdStage);

          var tdUpd = document.createElement("td");
          tdUpd.textContent = fmtDate(c.updatedAt);
          tdUpd.style.color = "#888";
          tr.appendChild(tdUpd);

          /* Action icons. stopPropagation keeps icon clicks from also
       triggering the row's open-pane handler. Glyphs are set via
       unicode escapes to keep the source ASCII. */
          var tdActions = document.createElement("td");
          tdActions.className = "action-cell";
          function addIcon(glyph, title, handler) {
            var btn = document.createElement("button");
            btn.className = "action-btn";
            btn.type = "button";
            btn.title = title;
            btn.textContent = glyph;
            btn.addEventListener("click", function (ev) {
              ev.stopPropagation();
              handler(c);
            });
            tdActions.appendChild(btn);
          }
          addIcon("\u2709", "Compose email", openComposer);
          addIcon("\u260E", "Call linked contact", dialCard);
          addIcon("\u2197", "Expand card", openPopout);
          tr.appendChild(tdActions);

          tbody.appendChild(tr);
        });
      }

      /* -- Load + boot ------------------------------------------------- */

      async function loadData() {
        try {
          setLoading(true, "Reading board schema...");
          var schema = await fetchSchema(boardId());
          var F = resolveFields(schema, { stage: ["Stage"] });
          STATE.stageKey = F.stage;

          var cards = await fetchAllCards(boardId(), function (n, total, msg) {
            document.getElementById("loadingText").textContent =
              msg || "Loading cards... " + n + (total ? " of " + total : "");
          });
          STATE.cards = cards;
          STATE.activeStage = null;

          document.getElementById("timestamp").textContent =
            "As of " +
            new Date().toLocaleDateString(undefined, {
              year: "numeric",
              month: "long",
              day: "numeric"
            });
          setLoading(false);
          renderStageBoxes();
          renderTable();
        } catch (e) {
          setLoading(false);
          document.getElementById("dashboard").style.display = "none";
          showError(e && e.message ? e.message : String(e));
        }
      }

      var booted = false;
      function init() {
        if (booted) return;
        booted = true;
        loadData();
      }
      window.init = init;

      (function boot() {
        whenSdkReady(10000)
          .then(function (sdk) {
            if (sdk.token) {
              init();
              return;
            }

            /* requestToken({version: 2}) resolves { token, error } -- it does
         NOT reject on denial. Fail fast on errors; retry
         once on RATE_LIMITED. */
            if (typeof sdk.requestToken === "function") {
              var requestOnce = function () {
                return withTimeout(
                  sdk.requestToken({ version: 2 }),
                  8000,
                  "requesting the Aptly delegate token"
                );
              };
              return requestOnce()
                .then(function (result) {
                  var isV2 =
                    result && typeof result === "object" && "error" in result;
                  var error = isV2 ? result.error : null;
                  var token = isV2 ? result.token : result;

                  if (
                    error &&
                    error.code === "RATE_LIMITED" &&
                    error.retryAfterMs
                  ) {
                    setLoading(
                      true,
                      "Token request rate limited -- retrying in " +
                        Math.ceil(error.retryAfterMs / 1000) +
                        "s..."
                    );
                    return delay(error.retryAfterMs)
                      .then(requestOnce)
                      .then(function (r2) {
                        if (r2 && r2.error)
                          throw new Error(
                            r2.error.message + " (" + r2.error.code + ")"
                          );
                        if (!(r2 && r2.token))
                          throw new Error("No token after rate-limit retry.");
                        init();
                      });
                  }
                  if (error)
                    throw new Error(error.message + " (" + error.code + ")");
                  if (!token)
                    throw new Error(
                      'Aptly returned no token. Ask an admin to enable "Needs Aptly Context" ' +
                        "in the board view settings for this tab and confirm this app is authorized."
                    );
                  init();
                })
                .catch(function (e) {
                  showError(
                    "Aptly token request failed: " +
                      (e && e.message ? e.message : String(e))
                  );
                });
            }

            /* Fallback for SDK builds without requestToken: bounded poll. */
            var MAX_CTX = 40;
            var attempts = 0;
            var poll = setInterval(function () {
              if (window.aptly && aptly.token) {
                clearInterval(poll);
                init();
                return;
              }
              attempts += 1;
              if (attempts >= MAX_CTX) {
                clearInterval(poll);
                if (!booted)
                  showError(
                    "No Aptly context received after " +
                      MAX_CTX +
                      " attempts. " +
                      'Ask an admin to enable "Needs Aptly Context" in the board view settings for this tab.'
                  );
              }
            }, 200);
          })
          .catch(function (e) {
            showError(e.message);
          });
      })();
    </script>
  </body>
</html>
```

***

## Using delegate tokens without the SDK

Server-side scripts, custom auth flows, and AI agents can use the raw token exchange
directly:

1. `POST https://core-api.getaptly.com/api/platform/user-token` with the logged-in
   user's session JWT in the `access_token` header and a body of
   `{ "readScopes": ["boards:*"], "writeScopes": [] }` -- or `{ "appClientId": "..." }`
   for registered marketplace apps. Optional `expirationSeconds` up to 604800
   (7 days); the default expiry is 5 minutes.
2. Verify identity when needed: `POST /api/app/verify` with `{ "token": "<token>" }`.
   No API key is required.
3. Call any supported route with `Authorization: DelegateToken <token>`.

Scopes follow `resource:qualifier` (`boards:*`, `boards:<boardId>`, `contacts:*`,
`knowledge:*`, `email:*`). Omitted scopes are denied; a missing scope returns `403`.
Request the narrowest scope the app needs -- a read-only dashboard needs only
`readScopes: ["boards:<boardId>"]`.

Full details: [Delegate Tokens](/delegate-tokens).

***

## Pre-deployment checklist

1. No calls to AI model APIs anywhere in the page.
2. No snapshot data embedded in the source; all data fetched at load.
3. SDK script tag points at `app.getaptly.com`.
4. Boot order: SDK readiness poll (bounded) -> existing token, else
   `requestToken({ version: 2 })` with fail-fast error handling and one
   `RATE_LIMITED` retry -> bounded token poll only as a v1 fallback.
5. Schema fetched before card data; fields resolved by label and key with variants;
   missing-field errors list the board's actual fields.
6. Pagination uses the `{ data, count }` envelope, caps pages, pauses between pages,
   and retries once on `429`.
7. Every error path renders in the UI; global `error` and `unhandledrejection`
   handlers installed.
8. No inline event handler attributes; all listeners attached with `addEventListener`.
9. DOM built with `createElement` and `textContent`; no HTML-string `innerHTML`.
10. Source is pure ASCII, has no `<` + letter sequences outside quoted strings, and
    keeps lines under 200 characters.
11. No tokens or other credentials in the source; developer mode (if present) accepts
    a pasted token at runtime.
12. The target board has API access enabled, and the tab has **Needs Aptly Context**
    turned on.
