Skip to main content
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:

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>:
<script src="https://preview.getaptly.com/api/ai/app-builder/sdk.js"></script>
The SDK is served from preview.getaptly.com only. After it loads, window.aptly is available:
// 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.
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:
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:
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:
{ "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 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).
SituationResponse
Board exists but API access is disabled400 with { "error": { "code": "API_DISABLED" } }
Board does not exist404 with { "error": { "code": "BOARD_NOT_FOUND" } }
Bearer scheme used instead of DelegateToken401 "Invalid or missing API key"
Token lacks the required scope403 Forbidden
Token expired or invalid401
Rate limit exceeded429, 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:
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.

Testing your app

Embedded context only exists inside Aptly, so local development uses one of three emulation options. An Aptly admin generates a long-lived developer token in Global Admin -> [Company] -> Dev Tokens. Call startEmulation after the SDK loads:
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:
<script>
  window.APTLY_DEV = {
    token: "YOUR_API_KEY",
    config: { BOARD_ID: "my-board" },
    org: { id: "your-company-id", name: "Acme" },
    user: { email: "[email protected]", firstName: "Dev" }
  };
</script>
<script src="https://preview.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 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:
await aptly.startDialer({ number: "+15551234567", name: "Jane Smith" });
await aptly.openEmailComposer({
  to: ["[email protected]"],
  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 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.
<!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://preview.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.

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 preview.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.