๐งญ Overview
What this workbook builds, who it's for, and what "done" looks like
This workbook is the hands-on track for the 2026 September BCN/LON partner worklab. Over one day you provision a real Commerce environment, then use an AI coding agent to extend it end to end โ business logic, Admin UI, storefront, and API orchestration โ the same way you would on a customer engagement. Every page maps to a section of the worklab outline, expanded into copy-paste-ready commands, agent prompts, and verification steps.
01Scan for This Workbook
https://qrfy.io/8GviRIA4j8
02Workshop Goal
By the end of the day you will have:
- A provisioned Adobe Commerce as a Cloud Service (ACCS) instance and an Adobe Commerce Optimizer (ACO) instance, each with a Storefront built via Site Creator.
- A scaffolded App Builder project, connected to a Developer Console project/workspace, deployed to Adobe I/O Runtime.
- Three business rules implemented end to end through an AI coding agent: a cart-size webhook, an order-approval event, and an order-to-ERP sync that stores its result in App Builder Database Storage (ABDB).
- That ERP ID surfaced in Commerce Admin's order grid, a manual-approval disclaimer on the storefront cart/checkout, and a tracking ID stitched through API Mesh into My Account.
- Hands-on experience operating the storefront โ publishing content, personalizing it, running an experiment, and scheduling a launch โ plus a written CI/CD recommendation for continuing to build on this app afterwards.
03Audience
Partner solution engineers and developers who will be building and demoing Adobe Commerce extensibility patterns for customers โ not backend engineers who already live in this stack daily. The workbook assumes comfort with a terminal, Git, and driving an AI coding agent (Cursor, Claude Code, VS Code + Copilot, or similar), but does not assume prior App Builder or ACCS/ACO experience.
04Final State
At the end of the day, each participant has: two provisioned Commerce environments (ACCS and ACO) each with a live Storefront; one App Builder app deployed to a Stage workspace enforcing the three business rules; Admin and storefront UI reflecting that app's state; an API Mesh endpoint serving a tracking ID into My Account; live experience with publishing, personalization, experimentation, and scheduling on that Storefront; and a written CI/CD recommendation for building on this app going forward. Nothing here is throwaway โ the same app, storefront, and instances are used from Provisioning through Build/Dev Experience.
05Assumptions
- You have (or your facilitator has assigned you) access to an IMS organization with permission to create Developer Console projects and Commerce Cloud Manager instances โ Adobe Commerce Labs is the org used for this program unless your own org already has ACCS/ACO entitlement.
- Your machine can install global npm packages and run a local dev server (no fully locked-down corporate laptop).
- You have a GitHub account (personal or organizational) to own the Storefront repositories Site Creator scaffolds โ decide this before Provisioning, since Storefront creation asks for it.
- You're working with a supported AI coding agent already installed (Cursor, Claude Code, VS Code + GitHub Copilot, Windsurf, Gemini CLI, OpenAI Codex, or Cline). The Tooling page assumes one is present and focuses on wiring Commerce-specific skills into it, not installing the agent itself.
06What's in This Workbook
| Page | Summary | Time |
|---|---|---|
| Overview | This page โ goal, audience, final state, assumptions. | โ |
| Tooling | Install Node/AIO/AEM CLIs, install the AI agent skills, validate CDA access, and install the AEM Sidekick browser extension. | ~20 min |
| Provisioning | Stand up ACCS, ACO, and their Storefronts. | ~30 min |
| Extend Business Logic | Scaffold the App Builder project, then build a cart-size webhook, order-approval event, order-to-ERP sync into ABDB, and surface the ERP ID in Commerce Admin. | ~2 hrs 15 min |
| Extend Storefront | Build a custom Office & Paper category landing page โ learn EDS fundamentals (documents, blocks, drop-ins) while building it, then add a manual-approval disclaimer, a Product Compare block, and a live, shareable compare bar. | ~2.5 hrs |
| API Orchestration | API Mesh between Storefront and runtime actions for a tracking ID in My Account. | ~45 min |
| Operate Your Storefront | Publish a teaser on DA.live, personalize content, (optionally) run an experiment, schedule a launch, (optionally) add Product Recommendations, and tour Experience Workspace. | ~90 min |
| Build / Dev Experience | A CI/CD recommendation for this app. | ~20 min |
| Blueprint with CDA (bonus) | Build a small App Builder app blueprint-first inside the hosted Commerce Developer Agent. | ~30 min (optional) |
| Optional Exercises (bonus) | Take-home extensions to the Extend Business Logic app โ not part of the live day. | ~4-6 hrs (optional, at home) |
The time values above are the exact estimates registered in each page's own frontmatter (
time:) โ the Path bar and pager use the same numbers.
๐ ๏ธ Tooling
CLI, AI agent skills, and CDA access
Everything after this page assumes a working machine: the right CLI versions, the Commerce agent skills installed, confirmed access to the hosted Commerce Developer Agent (CDA), and the AEM Sidekick browser extension. Do all four steps before Provisioning โ Extend Business Logic's App Builder scaffold step depends on the skills installed here, and Operate Your Storefront / Campaign Ops both assume Sidekick is already installed.
Install core CLI tooling
Install Node.js, the Adobe I/O CLI, and the AEM CLI, then authenticate the Adobe I/O CLI against the IMS organization you'll use for the rest of the day.
Install Node.js 22+ (LTS). On macOS via Homebrew:
BASHbrew install node@22 echo "export PATH=\"$(brew --prefix node@22)/bin:\$PATH\"" >> ~/.zshrc source ~/.zshrcOn Windows, install from nodejs.org or
winget install -e --id OpenJS.NodeJS.LTS, then run everything else from Git Bash, not PowerShell โ later lab commands rely oncurl,jq, and POSIX paths that native PowerShell handles differently.Install the Adobe I/O CLI and the AEM CLI together:
BASHnpm install -g @adobe/aio-cli @adobe/aem-cliAuthenticate the Adobe I/O CLI (this is
aio auth login, notaio login):BASHaio auth loginInstall the API Mesh and Commerce Adobe I/O CLI plugins โ API Orchestration and Extend Business Logic's storefront setup both assume these are already installed:
BASHaio plugins:install @adobe/aio-cli-plugin-api-mesh aio plugins:install https://github.com/adobe-commerce/aio-cli-plugin-commerce
aio --version or aem --version returns "command not found".
Cause: The global npm bin directory isn't on your PATH, or the install ran against a different Node version than the one now active (common after switching Node via nvm/Homebrew after install).
Fix: Re-run npm install -g @adobe/aio-cli @adobe/aem-cli with Node 22 active, then open a new shell so PATH picks up the global bin directory.
aio where shows the wrong IMS organization.
Cause: A previous aio project on this machine left a different org selected โ aio auth login doesn't reset the org selection by itself.
Fix: Run aio console org select, choose the correct organization for this workshop, then re-run aio where to confirm.
aio plugins doesn't list @adobe/aio-cli-plugin-api-mesh or @adobe/aio-cli-plugin-commerce.
Cause: One of the two aio plugins:install commands failed silently (e.g. a network blip or npm registry timeout) or was skipped.
Fix: Re-run the missing plugin's install command individually, then re-check with aio plugins.
Check it works
0/3Run these checks after installing and authenticating the CLIs.
node --version # must be v22.x or newer
aio --version
aem --version
Each command returns a version string with no "command not found" error.
aio where
Prints the IMS organization you logged-in.
aio plugins
Lists both plugins, each with a version (no "not found" or empty output):
@adobe/aio-cli-plugin-api-mesh 5.7.2
@adobe/aio-cli-plugin-commerce 0.9.0
โโ @oclif/plugin-help 6.3.0
Note you could have more aio plugins installed.
Install the AI agent skills
Install the Commerce agent skills before running any agent prompt in this workbook โ every step in Extend Business Logic, starting with its App Builder scaffold step, assumes they're present.
Create a dedicated project folder for the day's work and
cdinto it. Every later command in this workbook, including Extend Business Logic's App Builder scaffold step, assumes you're running from this folder:BASHmkdir && cdInstall the necessary skills for the labs
BASHnpx skills add adobe/skills \ --skill commerce-app-init \ --skill commerce-app-eventing \ --skill commerce-app-webhooks \ --skill commerce-app-business-config \ --skill commerce-app-admin-ui \ --skill commerce-app-storage \ --skill appbuilder-project-init \ --skill commerce-app-api-mesh \ --yes
ls .agents/skills/ is empty or missing directories after running the install command.
Cause: One of the seven chained npx skills add commands failed silently mid-chain (e.g. a network blip), so the && chain stopped before reaching the rest.
Fix: Re-run the failed skill's npx skills add command individually, then re-check ls .agents/skills/ for all seven directories.
Cause: The agent session was started (or its skill index cached) before the skills were installed, or it's running from a different working directory than the project root the skills were installed into.
Fix: Restart your AI coding agent session from the project folder root so it re-scans .agents/skills/.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
ls .agents/skills/
Lists all seven directories: appbuilder-project-init,
commerce-app-admin-ui, commerce-app-business-config,
commerce-app-eventing, commerce-app-init, commerce-app-webhooks,
commerce-app-storage.
Ask your AI coding agent to list its available skills/commands. At least
one of the new skills should appear, confirming it picked up
.agents/skills/ from this project's root.
Validate CDA access
Confirm you can reach the hosted Commerce Developer Agent (CDA) before you need it. This is the browser-hosted surface for the same agent skills and MCP tooling you just installed locally โ useful when you want to blueprint or scaffold without a local toolchain, or as a fallback if your local coding agent isn't cooperating.
- Open the CDA in your browser:
https://experience.adobe.com/#/commerce-migration-assessment/developer-agent
- Sign in with your Adobe ID and confirm the org picker shows the
correct IMS organization (the same one you selected with
aio console org select/ confirmed withaio wherein Step 1). - Confirm the Developer Agent surface loads without an entitlement or permission error. If it doesn't load, you're most likely in the wrong IMS org, or your org isn't entitled to the Commerce Migration Assessment application โ flag this to your facilitator now rather than during Provisioning.
Cause: You're signed in under the wrong IMS organization, or your org isn't entitled to the Commerce Migration Assessment application the CDA is hosted under.
Fix: Switch organizations in the Adobe ID org picker to match aio where's output; if the correct org still errors, flag it to your facilitator before Provisioning rather than troubleshooting further on your own.
Cause: A stale Adobe ID session, or a browser extension (ad blocker, strict cookie blocking) is interfering with the IMS sign-in redirect.
Fix: Retry in an incognito/private window with extensions disabled, or sign out of all Adobe ID sessions first and sign back in.
Cause: A previous Adobe ID session is still cached in the browser.
Fix: Sign out of all Adobe ID sessions (or use an incognito window), then sign back in and re-check the org picker.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
The Developer Agent surface at the CDA URL loads fully โ no blank page, no permission/entitlement error banner.
The org picker inside the CDA shows the same IMS organization confirmed
with aio where in Step 1.
Install the AEM Sidekick browser extension
Install AEM Sidekick, the browser extension used to edit, preview, and publish storefront content in DA.live throughout this workbook โ Operate Your Storefront and Campaign Ops both open pages through its Edit action.
- Install Sidekick from the Chrome Web Store (works in Google Chrome and other Chromium-based browsers):
https://chromewebstore.google.com/detail/aem-sidekick/igkmdomcgoebiipaifhmpfjhbjccggml
- Using Microsoft Edge? Enable "Allow extensions from other stores" at
edge://extensionsfirst, then install from the same Chrome Web Store link above. - Pin the extension to your toolbar so its icon is visible while you work.
- There's no storefront project of your own yet โ that's created in Provisioning. Sanity-check the extension now against a public demo project instead, e.g. https://main--aem-boilerplate--adobe.aem.page/. Sidekick should auto-detect it and show its Edit/Preview/Publish actions.
- If a project doesn't auto-detect (common on custom domains, which
Sidekick can't infer from the URL alone), open the menu and choose
Add this project, or add it manually from a URL matching
https://main--repo--owner.aem.page/. You'll use this same mechanism once your own storefront exists. - Once Provisioning creates your storefront, revisit Sidekick on its preview URL and confirm it auto-detects that project too โ that's the one you'll actually author against for the rest of this workbook.
Cause: The project wasn't auto-detected, or the demo URL has changed/moved.
Fix: Open the Sidekick menu and choose Add this project with the URL above, or try any other known https://main--repo--owner.aem.page/ site. Auto-detection failing here on your own storefront later (in Provisioning) has the same fix.
Cause: Edge disables extensions from outside the Microsoft Store by default.
Fix: Enable "Allow extensions from other stores" at edge://extensions, then retry the install.
Cause: The site has authentication for authors enabled and the Sidekick sign-in didn't complete.
Fix: Re-open the Sidekick menu and sign in again; retry in an incognito window if a stale session is interfering.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
The AEM Sidekick icon appears in the browser toolbar (pinned, not buried in the extensions menu).
Opening the demo preview URL above shows the Sidekick panel with Edit/Preview/Publish actions, not a "no project detected" state โ proof the extension itself works, ahead of having your own storefront to test against.
๐ Provisioning
ACCS and ACO, and their Storefronts
Stand up the two Commerce environments this workbook builds against โ an ACCS instance and an ACO instance, each with its own Storefront. Extend Business Logic picks up from here to scaffold the App Builder project and wire its webhook/event tests against these Storefronts, so do these steps first. Step 3 is optional, self-study only โ the live event does not cover it.
Provision ACCS
Create an Adobe Commerce as a Cloud Service instance and its Storefront.
Create the instance via Commerce Cloud Manager:
- Open Commerce Cloud Manager.
- Click Add Instance in the top-right corner.
- Select Commerce as a Cloud Service.
- Enter a Name and Description.
- Choose Environment Type: Sandbox for this workbook (Sandbox instances are restricted to the North America region regardless of where you are).
- Select the region: North America, then click Add Instance.
Once provisioned, open the instance from Commerce Cloud Manager and sign in to Commerce Admin via IMS.
Import catalog sample data into the instance.
- In Commerce Admin, go to System โ Import.
- Select Product from Entity Type.
- Select Add/Update from Import Behaviour.
- Click on Choose file in File to Import and select the downloaded file from resources.
- Click Check Data, and after data is validated, click Import.
- Wait for the import to complete โ the grid should now show products from the sample data.
- Download the images.zip file from resources and extract it to a local folder.
- Then, go to Catalog โ Product and upload an image to each product created.
Create a Storefront via Site Creator:
- Get the instance's GraphQL endpoint from Commerce Cloud Manager (you'll need this in Step 4 below and again in API Orchestration).
- Open Site Creator.
- Select Create New Site (Code & Content).
- Provide your GitHub organization or username, a Site Name, and the Commerce GraphQL endpoint.
- Click Create Site and authorize GitHub repository access when prompted.
- Note the four URLs Site Creator hands back: the code repository, the
da.livecontent editor, the config manager (config.json), and the.aem.pagepreview URL.
Clone the Storefront project locally:
BASHgit clone https://github.com/<org>/<repo>.git cd <repo> npm install npm startDisable AEM Assets in the Storefront's
config.json: setcommerce-assets-enabledtofalseunderpublic.default. This workbook doesn't use AEM Assets, and leaving ittrue(the boilerplate's default) breaks product image rendering since the drop-ins expect AEM Assets URLs that don't exist here. Commit and push the change:BASHgit add config.json git commit -m "Disable AEM Assets (not used in this workbook)" git pushInstall SKILLS.md to the Storefront so your AI coding agent gets the storefront-specific skills and the
commerce-extensibilityMCP server (/dropin-developer,/block-developer,/content-modeler) needed in Extend Storefront and the API Orchestration pages. Run from inside the cloned Storefront repo:BASHaio commerce extensibility tools-setup --starter-kit aem-boilerplate-commerce
Cause: Provisioning in the selected region is degraded, or you don't actually hold the entitlement/quota to create a new instance in the chosen org.
Fix: Confirm your org and entitlement with your facilitator before retrying โ don't click Add Instance repeatedly, since each attempt can consume credits per this instance's own metering rules.
Cause: This isn't a provisioning failure โ the Adobe ID that provisioned the instance was never granted admin access on the "Adobe Commerce - Commerce Cloud Manager" product profile in Admin Console, so it maps into Commerce Admin as a restricted/developer-level user by default.
Fix: In Adobe Admin Console, have a System/Product Administrator add the affected user with admin permissions to the "Adobe Commerce - Commerce Cloud Manager" product profile, then log out and back in to Commerce Admin โ the full menu appears immediately, no re-provisioning needed.
Cause: GitHub repository access wasn't authorized when prompted, or the GraphQL endpoint pasted from Commerce Cloud Manager is stale or incorrect.
Fix: Re-run Site Creator, explicitly grant GitHub access when prompted, and re-copy the GraphQL endpoint directly from Commerce Cloud Manager rather than reusing one from memory.
npm start fails in the cloned Storefront repo.
Cause: The active Node.js version in this shell doesn't match the Node 22+ installed in Tooling, or npm install didn't complete cleanly.
Fix: Confirm the active Node version matches Tooling Step 1's requirement, delete node_modules, re-run npm install, then retry npm start.
> Gap in the outline, flagged explicitly: the worklab outline says
> "Create ACCS instance (with sample data)." Nothing in the
> commerce-practice-builder provisioning material supports that โ it
> states plainly that "the merchant's first blocker is an empty catalog,
> not permissions" and that someone has to import data before a demo can
> happen. Recommendation: treat sample-data import as an explicit,
> separate task after instance creation (via Commerce Admin's Import tool
> or the Product Visuals bulk-ingest flow), and don't assume a freshly
> created ACCS instance ships with a catalog already loaded. Confirm with
> your facilitator whether a pre-seeded catalog is provided for this
> program, or plan ~15โ20 extra minutes to import one before you need
> products for cart testing in Extend Business Logic. A ready-to-import
> 10-product sample (CSV + images) is linked under Resources below if you
> need one on the spot.
Check it works
0/7Expand a check for verification steps, or mark confirmed if you already know it passes.
The new instance appears in Commerce Cloud Manager's instance list with a "Ready"/"Active" status, not still provisioning.
Signing in to Commerce Admin via IMS from the instance succeeds and lands on the Admin dashboard.
In Commerce Admin, go to Catalog โ Products and confirm the grid isn't empty โ it should list products from the imported sample catalog CSV file. If it's empty, please create a simple product. It will be needed in the next section Extend Business Logic.
Site Creator's four returned URLs (code repo, da.live editor,
config.json, .aem.page preview) each load without a 404 or
access-denied error.
npm start
Serves the site locally, and the local page matches what renders at the
.aem.page preview URL.
grep commerce-assets-enabled config.json
Shows "commerce-assets-enabled": false, and product images load
correctly on the PLP/PDP โ not broken/missing, which is what happens if
this is left at the boilerplate's default true without AEM Assets
configured.
After running tools-setup, the storefront should include AGENT.md,
skills folder with block-developer, content-modeler,
dropin-developer, project-manager, researcher, tester skills,
and two MCPs, commerce-extensibility and dropins.
Provision ACO
Create an Adobe Commerce Optimizer instance, ingest a catalog into it, and create its Storefront.
Create the instance via the same Commerce Cloud Manager flow as ACCS, selecting the Commerce Optimizer instance type instead:
- Open Commerce Cloud Manager.
- Click Add Instance in the top-right corner.
- Select Commerce Optimizer.
- Enter a Name and Description.
- Choose Environment Type: Sandbox for this workbook (Sandbox instances are restricted to the North America region regardless of where you are).
- Select the region: North America.
- Click Add Instance.
- The ACO instance appears in Commerce Cloud Manager alongside any ACCS instances in the same org. Note its tenant ID โ you'll need it for both ingestion and the Storefront's GraphQL endpoint.
Ingest data into the instance. ACO has no Admin UI for catalog import โ data goes in exclusively through the Data Ingestion API (a REST, IMS-authenticated API). For this workbook, use the sample ingestion SDK against the fictional Carvelo dataset:
Create IMS credentials in the Adobe Developer Console: add API โ Adobe Commerce Optimizer Ingestion โ select the Default - Cloud Manager profile โ save, then copy the OAuth Server-to-Server Client ID and Client Secret.
Clone and configure the sample ingestion repo:
BASHgit clone https://github.com/adobe-commerce/aco-sample-catalog-data-ingestion.git cd aco-sample-catalog-data-ingestion npm install # needs Node.js 20.14.0+ cp .env.dist .envFill in
.envwithTENANT_ID,REGION,ENVIRONMENT,CLIENT_ID,CLIENT_SECRET, then run the sample's ingestion script to load product metadata, products, price books, prices, and categories into the base catalog (see the repo's own README for the exactnpm runscript name at the version you clone).
Gap in the outline, flagged explicitly: the outline's "Ingest data into the instance" bullet doesn't say which data or how. In practice there are two realistic ingestion paths and the outline is silent on which applies to this workbook:
Sample/synthetic data (what's checked above) โ the Carvelo dataset via
aco-sample-catalog-data-ingestion, useful for a clean demo with no dependency on a real backend.A real third-party catalog (PIM, ERP, or an existing Adobe Commerce instance) โ via the Data Ingestion API directly, the
@adobe-commerce/aco-ts-sdk, or (if the source of truth is an existing Adobe Commerce instance rather than a PIM) the no-code ACO connector instead of custom ingestion code.Recommendation: use the Carvelo sample path for this workbook โ it's scripted, requires no partner-specific backend, and is what the available source material documents end to end. Only reach for the connector or custom SDK ingestion if you're demoing against a real customer catalog.
Create a Storefront via Site Creator for ACO, the same flow as ACCS Step 1 above, but pointing the GraphQL endpoint field at your ACO tenant's Merchandising GraphQL API (or an API Mesh endpoint that fronts it) instead of the ACCS endpoint.
Create a Catalog view over the ingested catalog โ the Storefront needs a view ID and a price book ID before it can query ACO, so do this before recording those values into
config.jsonbelow:- Open Experience Cloud, open the ACO instance's details, and go to Catalog views.
- Click Create catalog view and fill in the form: give it a
Name, select the Catalog source you ingested into (e.g.
en-US), and under Price books pick a Price book availability mode (e.g. Single price book only) and the Price book to use (e.g. Global visibility price book). - Submit and wait for the catalog view to finish provisioning.
- Back in the Catalog views list, open the view you just created and copy its View ID.
Record the View ID and price book ID in the Storefront's
config.json: setAC-View-IDandview-idto the View ID copied above, andAC-Price-Book-IDto the ID of the price book selected in the catalog view.JSON{ "public": { "default": { ... "headers": { "cs": { "AC-View-ID": "<view-id-from-catalog-view>", "AC-Price-Book-ID": "<price-book-id>" } }, ... "analytics": { ... "view-id": "<view-id-from-catalog-view>" } ... } } }Commit and push the change so the config is available to the rest of the team:
BASHgit add config.json git commit -m "Set ACO catalog view and price book IDs" git pushInstall SKILLS.md to this Storefront the same way as ACCS:
BASHaio commerce extensibility tools-setup --starter-kit aem-boilerplate-commerceVerify search works end-to-end on the ACO Storefront, either locally (
npm start) or on the pushed.aem.livesite once the code is live:- Open the storefront and click the search (magnifying glass) icon in the header.
- Search for a term you know exists in the ingested catalog (e.g. a product name or category from the Carvelo dataset, such as "tire").
- Confirm results are returned, then open one to confirm its PDP (product detail page) renders correctly.
Cause: The IMS OAuth Server-to-Server credentials in .env are missing, use the wrong Client ID/Secret, or weren't created against the Commerce Optimizer Ingestion API / Default - Cloud Manager profile.
Fix: Re-create the credentials in Adobe Developer Console following the exact API/profile named above, re-copy CLIENT_ID/CLIENT_SECRET into .env, and re-run the script.
Cause: The Storefront's GraphQL endpoint points at the wrong tenant ID or region, or ingestion wrote to a different environment (staging vs. production) than the Storefront queries.
Fix: Re-confirm the tenant ID and region from Commerce Cloud Manager, and make sure the TENANT_ID/REGION/ENVIRONMENT values used for ingestion match what's fed into Site Creator's GraphQL endpoint field.
TENANT_ID/REGION/ENVIRONMENT in .env are unclear or don't match anything visible in Commerce Cloud Manager.
Cause: These values come from the ACO instance's own detail view, not the ACCS one โ easy to copy the wrong instance's identifiers when both exist in the same org.
Fix: Open the ACO instance specifically in Commerce Cloud Manager and copy its tenant ID/region directly, rather than reusing values noted for the ACCS instance.
Check it works
0/6Expand a check for verification steps, or mark confirmed if you already know it passes.
The ACO instance appears in Commerce Cloud Manager with a "Ready"/"Active" status, alongside any ACCS instance in the same org.
The sample ingestion script completes without an authentication error and reports products/categories/price books loaded (exact counts depend on the Carvelo dataset version you cloned).
The catalog view appears in Catalog views with the ingested catalog
source and price book applied, and its View ID has been copied into
the Storefront's config.json (AC-View-ID / view-id) along with the
price book's ID (AC-Price-Book-ID).
The ACO Storefront's .aem.page preview URL renders products from the
ingested catalog, confirming the GraphQL endpoint points at the right
tenant.
After running tools-setup, the storefront should include AGENT.md,
skills folder with block-developer, content-modeler,
dropin-developer, project-manager, researcher, tester skills,
and two MCPs, commerce-extensibility and dropins.
Searching for a known catalog term on the ACO Storefront (locally via
npm start, or on the pushed .aem.live site) returns results, and
opening a result loads its PDP without error โ confirming the
Storefront, search index, and catalog view are all wired together
correctly.
(Optional) AEM Assets Integration primer
This step is optional and self-study only โ facilitators will not cover it during the live event. No source material in this project's two source repos covers configuring this integration, and these workshop tenants don't have it provisioned either. It's included here because "how do product images get managed at scale?" is a recurring architecture question when scoping catalog/media strategy for a customer โ read it on your own time, and verify specifics against a licensed environment before committing to them in a solution design or SOW.
The problem it solves: a retailer with a few thousand SKUs, multiplied
across regions, seasons, and campaign variants, ends up needing far more
image variants than is practical to maintain by hand in Commerce's own
catalog media fields. AEM Assets Integration makes AEM's DAM the source
of truth for that imagery, while Commerce (or ACO) stays the system of
record for catalog data. Assets are matched to products via the
commerce:skus metadata field; once tagged (by hand or an automated
pipeline) and approved/updated in AEM, an asynchronous sync pushes the
image into Commerce (a changed asset may need reprocessing before
re-approval). End to end: AEM asset โ metadata/SKU match โ approval โ
sync โ storefront render. Linking multiple asset variants to a SKU
doesn't, by itself, pick the right one per visitor, region, or language โ
that's storefront/catalog logic, not this integration.
Current scope, specific to Product Visuals/ACO: product images only today โ no category images, video, image ordering, or full multi-site distinction (a narrower per-website "hide image" feature exists, but full multi-site support is still on Adobe's roadmap). One catalog locale and layer per tenant. Confirm against current docs before assuming broader capability in a customer conversation.
| Responsibility | Where it lives |
|---|---|
| Asset storage, versioning, renditions | AEM Assets (DAM) |
SKU mapping (commerce:skus metadata) |
AEM Assets |
| Sync trigger | Asset approval/update event in AEM (async; may need reprocessing) |
| Catalog data ownership | Commerce/ACO โ this integration only supplies imagery |
| Rendering the synced image | Storefront (Edge Delivery), same PDP/PLP you smoke-tested in Steps 1โ2 |
Entitlement, by deployment: on SaaS (ACCS/ACO), the bundle you need is Product Visuals (Dynamic Media with OpenAPI capabilities + AEM Assets Prime), and it ships by default โ nothing to buy or verify. On PaaS (Commerce 2.4.5+), it's the opposite: AEM Assets licensing must be purchased separately, alongside the Commerce extension and Adobe I/O Events. That PaaS gap is the one that actually surfaces mid-engagement if it isn't checked during solutioning.
| Deployment | Setup guide |
|---|---|
| ACCS | Configure AEM Assets project โ IMS permissions โ Sync setup in Commerce Admin โ Enable product-image display โ onboarding has since moved to a faster, single-env-var path; confirm the current flow against these live docs |
| Adobe Commerce Optimizer | Configure AEM Assets for ACO |
| PaaS (2.4.5+) | Install Adobe Commerce packages, then the same AEM/IMS/sync/storefront guides as ACCS |
Heuristic, not Adobe policy (this workbook's own judgment call, not a documented Adobe decision tree): lean toward recommending AEM Assets Integration when a customer has two or more of:
- A catalog large enough (thousands of SKUs+) that manual image upload is already a known pain point.
- Multiple regions, brands, or segments needing different creative for the same product.
- An existing AEM Assets investment they'd otherwise duplicate.
- A region/season/campaign cadence outpacing their catalog-import process โ remember, picking the right variant per visitor is still storefront/catalog logic, not something this integration does for you.
Lean not recommending it when the catalog is small, single-region, and imagery rarely changes โ the licensing and setup overhead isn't worth it yet.
Partner Takeaways
- How an asset becomes a product image: SKU match (
commerce:skus) โ approval/update event โ asynchronous sync โ Storefront render. - SaaS gets Product Visuals by default; PaaS has to buy AEM Assets licensing separately โ that's the prerequisite people miss.
- The two-of-four framework above is this workbook's own heuristic, not Adobe policy โ validate it against your own practice's guidance before using it in a statement of work.
Cause: Product Visuals is a distinct product from the AEM Sites/Edge Delivery entitlement that powers Storefront Builder โ one doesn't imply the other.
Fix: Check entitlement explicitly before scoping this into a solution design (especially on PaaS, where it's a separate purchase) โ don't infer it from "they already use AEM."
๐ Extend Business Logic
App Builder Scaffold, Events, Webhooks, ABDB, and Admin UI
This page scaffolds the App Builder project the rest of this workbook extends, then builds the three business rules from the worklab outline into it: a webhook that blocks an oversized cart before Commerce accepts it, an event that puts high-value orders on hold for a human to approve, and a second event that synchronizes order data to a ERP and remembers the returned ERP ID in App Builder Database Storage (ABDB). Each step is independently testable โ do them in order, since Step 4 reads the order state Step 3 sets.
This is the starting picture handed off from Provisioning โ two Commerce environments, each with a Storefront, and nothing App-Builder-specific yet. Step 1 below scaffolds that App Builder project and associates it with your Commerce instance; every step after that builds on top of it:
01A note on extensibility approaches
Note that Adobe Commerce has many in-process extensibility methods โ plugins/interceptors, observers, layout XML, and dependency injection preferences โ but these are not covered in this workshop. Instead, this workshop focuses on out-of-process extensibility: App Builder actions, webhooks, events, and the Admin UI SDK, all of which run outside the Commerce PHP monolith and integrate over HTTP/eventing rather than by touching Commerce's own codebase. This is the pattern App Builder-based, SaaS/ACCS-first engagements should reach for first, since it works regardless of whether the target instance is SaaS, ACCS, or on-prem/PaaS, and keeps custom logic upgrade-safe and decoupled from Commerce core.
Scaffold the App Builder project
Create the empty App Builder project this workbook extends in every later
Build page. This is the one step in this section where an AI coding agent
does most of the work โ the one exception is subscribing
ACCS-REST-API, which needs a product profile picked by hand in
Developer Console.
cd into the project folder you created in Tooling Step 2 and open it in
your editor before running the prompt below:
cd
Prerequisite: run
ls .agents/skills/in this exact folder and confirmcommerce-app-initandappbuilder-project-initare listed before you run the prompt below, then start (or restart) your AI coding agent from this same folder. If the skills aren't present here โ even if you installed them correctly in Tooling Step 2 butcd'd into a different folder since, or your agent was already running before you installed them โ the agent has no way to find the Commerce app schema and will silently scaffold a generic App Builder app instead. That fallback path doesn't error:aio app buildandaio app deployboth succeed, so the failure only shows up later, in Confirm the Output below or at the Commerce Admin Associate step. See Troubleshooting if that happens to you.
Initialize a new Commerce app in this project folder, using the
commerce-app-init and appbuilder-project-init skills โ not a generic App
Builder scaffold. The result must include app.commerce.config.ts and an
extensibility package (src/commerce-extensibility-*/ext.config.yaml), not
just app.config.yaml.
Create an Adobe Developer Console project and a Stage workspace in my IMS
organization, and connect this app to that workspace.
Subscribe all of the following APIs to the Stage workspace:
- I/O Management API
- App Builder Data Services
- I/O Events
- Adobe I/O Events for Adobe Commerce
Create a .gitignore file that includes: .aio, .env, node_modules/, dist/,
and a glob pattern covering any workspace JSON files downloaded from the
Developer Console (e.g. workspace-*.json, *-workspace.json).
When you are done, run a build and deploy to Runtime, confirm the
app-management/app-config and app-management/association actions
specifically are live.
Subscribe ACCS-REST-API
Subscribe ACCS-REST-API on the Stage workspace โ App Management
association (Confirm the Output #5 below) requires it, and so does any
runtime action that calls Commerce REST APIs directly (for example, Step
3's order-hold handler). This one requires picking a product profile, so
do it by hand in Developer Console rather than asking the agent:
- Open your project's Stage workspace in Adobe Developer Console.
- Click Add service โ API.
- Select Adobe Commerce as a Cloud Service.
- Under Server-to-Server Authentication, choose OAuth Server-to-Server.
- Select the product profile of your Commerce instance, then save.
Associate in Commerce Admin
Your agent already built and deployed the app as the last step of the
Agent Prompt above, but that alone does not connect it to your Commerce
instance โ you still have to manually associate it: in Commerce
Admin โ Apps โ App Management โ Associate App, select your project and
Stage workspace. This is what links the deployed
app-management/app-config and app-management/association actions to
this Commerce instance, and every later step's re-association (Steps 2, 3,
4, and 5) depends on this first association having succeeded. Association
completes without a compatibility error โ see Troubleshooting below if it
doesn't.
| Piece | Location |
|---|---|
| Console project/workspace | Developer Console, e.g. project CommerceAppWorklab, workspace Stage |
| Commerce app scaffold | app.commerce.config.ts, app.config.yaml |
| Extensibility package | src/commerce-extensibility-1/ext.config.yaml |
| Ignore rules | .gitignore โ .aio, .env, node_modules/, dist/, workspace-*.json |
Developer Console project names must be alphanumeric only (no hyphens) โ
CommerceAppWorklab, notcommerce-app-worklab. The local folder name can differ from the Console project name.
aio app build and aio app deploy both succeed with no errors, but the project only has app.config.yaml (no app.commerce.config.ts or src/commerce-extensibility-*/), and aio runtime action list shows generic action names (e.g. generic, publish-events) instead of app-management/app-config and app-management/association.
Cause: .agents/skills/ wasn't present (or wasn't in the folder the agent was actually running from) when the Agent Prompt ran. With no commerce-app-init/appbuilder-project-init skill to find, the agent fell back to a plain aio app init scaffold โ a fallback that builds and deploys cleanly, so nothing signals the mistake until Confirm the Output or the Commerce Admin Associate step below.
Fix: Run ls .agents/skills/ from the same folder your agent is running in and confirm all seven skills from Tooling Step 2 are listed; if they're missing, install them there (Tooling Step 2) or restart the agent from the correct folder. Then re-run the Agent Prompt in a clean project folder โ don't layer the Commerce scaffold on top of the generic one, undeploy/delete the generic attempt first.
Cause: App Management association requires a deployed app with generated App Management runtime actions (app-management/app-config at minimum) โ a local build alone doesn't satisfy this check. On ACCS, a missing ACCS-REST-API subscription on the workspace can also block it.
Fix: Run aio app build && aio app deploy, confirm with aio runtime action list, and on ACCS subscribe ACCS-REST-API on the workspace (aio console workspace api add --projectName CommerceAppWorklab --workspaceName Stage --service-code ACCS-REST-API) before retrying Associate.
aio app build and aio app deploy both succeeded and aio runtime action list shows app-management/app-config and app-management/association live.
Cause: The deploy ran as aio app deploy --no-publish, which skips publishing the app's extension point (commerce/extensibility/1) to the Adobe Exchange registry. App Management's Associate flow checks that registry to confirm the app declares a valid Commerce extension point, so an unpublished app looks incompatible even though its runtime actions are live.
Fix: Re-run aio app deploy without --no-publish โ check your deploy command, script, or agent instructions for a stray --no-publish flag first. Confirm the fix in the deploy output: it should list the extension point under New Extension Point(s) in Workspace. Then retry Associate.
ACCS-REST-API via aio console workspace api add fails with a "product profile required" error.
Cause: Unlike most other APIs, ACCS-REST-API requires a license configuration (product profile) at subscription time.
Fix: Pass --license-config, e.g. aio console workspace api add --projectName CommerceAppWorklab --workspaceName Stage --service-code ACCS-REST-API --license-config "ACCS-REST-API=Default - Cloud Manager, Developers". If that profile name doesn't match your org, check the available profiles in the Developer Console's workspace API subscription UI.
aio console project create fails with "Project name ... is invalid. It should only contain alphanumeric values."
Cause: Hyphens or other special characters were used in -n.
Fix: Use an alphanumeric-only name (e.g. CommerceAppWorklab) for -n, and put anything human-readable in -t (title) instead.
aio app use workspace-config.json --no-input fails with "Incomplete .aio configuration" on a brand-new folder.
Cause: First-time wiring needs a Console configuration file downloaded first โ there's no .aio to merge into yet.
Fix: aio console org select <org-id>, aio console project select CommerceAppWorklab, aio console workspace select Stage, then aio console workspace download workspace-config.json before aio app use workspace-config.json --no-input --overwrite.
Before you start Step 2: point your Storefront at this app so you can trigger the webhook by adding to cart. If it isn't already running:
BASHaio commerce extensibility app-setup -s aem-boilerplate-commerce -n storefront cd storefront && npm run start
Check it works
0/5Expand a check for verification steps, or mark confirmed if you already know it passes.
ls .agents/skills/
Still shows the skills installed in Tooling Step 2 (commerce-app-init,
commerce-app-eventing, commerce-app-webhooks,
commerce-app-business-config, commerce-app-storage,
appbuilder-project-init).
app.commerce.config.ts exists with a valid metadata.id (alphanumeric,
hyphens only) and numeric semver version.
aio app build
Succeeds with no errors.
aio where
Shows your org, the CommerceAppWorklab project (or whatever name your
agent used), and workspace Stage.
aio runtime action list
aio runtime action list shows app-management/app-config, and
app-management/association live. Do not treat aio app get-url
output as proof of deployment โ it can print URLs even when
nothing is actually deployed to Runtime.
The Associate in Commerce Admin step above completed without a compatibility error.
Validate maximum cart items via webhook
Block an add-to-cart when the cart's total quantity would exceed a
merchant-configurable maximum. This is a synchronous guardrail: Commerce
calls your runtime action and waits for success or exception before it
commits the quote item.
Add a validation webhook that runs before a product is added to the cart.
Use the Commerce webhook method observer.sales_quote_item_save_before (type
before) โ do not use observer.checkout_cart_product_add_before,
observer.sales_quote_add_item, or any other event.
Calculate the total by summing all quote line quantities plus the quantity
of the item being added. If the same SKU already exists in the quote,
exclude its existing quantity to avoid double-counting.
If the total is greater than the maximum allowed, block the add and show:
"You have reached the maximum amount of items."
The maximum allowed must be configurable in Commerce Admin as
max_cart_units, with default 10.
Map payload fields using name/source pairs:
- name: item.qty, source: data.item.qty
- name: item.sku, source: data.item.sku
- name: quote, source: context_checkout_session.get_quote[items.qty,items.sku]
Set required: true and fallback_error_message: "You have reached the
maximum amount of items." on the webhook config.
When blocking the add, do not rely only on a generic exceptionOperation()
call โ the response body must include a type so Commerce serializes it as a
GraphQL input exception, not a generic class:
{
"op": "exception",
"message": "You have reached the maximum amount of items.",
"type": "\\Magento\\Framework\\GraphQl\\Exception\\GraphQlInputException"
}
Use the commerce-app-webhooks and commerce-app-business-config skills. Add
tests for the quantity calculation, same-SKU double-count prevention,
webhook field mapping, and the exception response shape. Build and deploy
when ready.
Re-associate and Install
Your agent already builds and deploys as the last step of the prompt above, but that alone does not register anything on Commerce โ you still have to manually re-associate and install the app before the webhook exists: in Commerce Admin โ Apps โ App Management, unassociate then re-associate the app with the same Developer Console project/workspace, and click through the installation wizard to Install. That Install step is what actually registers the webhook on the Commerce instance โ skipping it leaves the handler deployed to Runtime but never invoked.
| Piece | Location |
|---|---|
| Webhook registration | app.commerce.config.ts โ webhooks[] |
| Business config | businessConfig.schema โ max_cart_units (default 10) |
| Handler | src/.../actions/validate-cart-units/index.js |
| Action manifest | ext.config.yaml โ suggested package commerce_worklab (underscore) |
Commerce event: observer.sales_quote_item_save_before (type before)
Field mapping: item.qty โ data.item.qty, item.sku โ
data.item.sku, quote โ context_checkout_session.get_quote[items.qty,items.sku]
The action/package names are whatever your agent produces โ read what it wrote back and use those names in the checks below.
Cause: App not deployed, or the app was never re-associated and installed after deploy.
Fix: Run aio app deploy, then unassociate/re-associate and complete the Install step in Commerce Admin, and confirm the hook appears for observer.sales_quote_item_save_before in the install summary.
Cause: Commerce applies a circuit breaker after repeated action failures (e.g. 400s). The hook still shows Active in Admin but is silently suspended internally.
Fix: Uninstall and reinstall the app from Commerce Admin. Toggling Active off/on is not sufficient to clear the suspended state.
Cause: That button only validates the webhook's declarative configuration inside Commerce โ it never calls your Runtime action.
Fix: Test with a real add-to-cart from the storefront PDP and check aio runtime activation list or Commerce's own webhook debug logs.
Cause: The fields entry is using a dotted path as name without a separate source, or the source is missing bracket notation.
Fix: Use the exact name/source pair with bracket syntax: { name: "quote", source: "context_checkout_session.get_quote[items.qty,items.sku]" }.
Cause: Adding from the Product Listing Page drops the rejected-add exception message; this is storefront behavior, not a webhook defect.
Fix: Verify from the PDP, where the exception message renders correctly.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
aio runtime action list
Shows the validation action live (for example
commerce_worklab/validate-cart-units).
In app.commerce.config.ts, confirm max_cart_units is in
businessConfig.schema and webhooks[] has an entry with
webhook_method: observer.sales_quote_item_save_before,
webhook_type: before.
The re-association and Install step above showed a webhook subscription step and finished without error โ this proves the hook is registered on the Commerce instance, not just deployed to Runtime.
Set Maximum cart units to 3 in the app's business configuration. On
the storefront Product Detail Page, add items from an empty cart
until the 4th add fails with "You have reached the maximum amount of
items." Adds below the limit succeed.
Hold high-value orders for manual approval (events)
React asynchronously once an order is placed: if the grand total exceeds a merchant-defined threshold, put the order on hold so a human reviews and approves it before it ships. Unlike Step 2's webhook, Commerce does not wait for this handler โ the order is already saved when your code runs.
Add a Commerce event subscription to observer.sales_order_save_commit_after.
Extract entity_id and grand_total from the Commerce event payload using
event fields in app.commerce.config.ts. The runtime action receives a
CloudEvents-shaped payload โ parse the extracted fields from
params.data.value, not directly from params.data:
- params.data.value.entity_id
- params.data.value.grand_total
When grand_total is greater than order_approval_threshold, put the order on
hold with state and status "holded" via a POST V1/orders Commerce REST call:
{
"entity": {
"entity_id": <entity_id>,
"state": "holded",
"status": "holded"
}
}
Make order_approval_threshold configurable in Commerce Admin, default 500.
Validate inputs before use: entity_id must be a positive integer,
grand_total must be a non-negative number. Skip (do not error) if either is
missing or the order is at/under the threshold.
Use the commerce-app-eventing skill. Add tests first, then implement. Run
tests, run aio app build, and deploy when ready.
Re-associate and Install
Your agent already builds and deploys as the last step of the prompt
above, but that alone does not register anything on Commerce โ you still
have to manually re-associate and install the app before the event
subscription exists: in Commerce Admin โ Apps โ App Management,
unassociate then re-associate the app with the same Developer Console
project/workspace, and click through the installation wizard to
Install. That Install step is what actually registers
observer.sales_order_save_commit_after on the Commerce instance โ
skipping it leaves the handler deployed to Runtime but never invoked.
| Piece | Location |
|---|---|
| Event subscription | app.commerce.config.ts โ eventing.commerce[] |
| Business config | order_approval_threshold (default 500) |
| Handler | src/.../actions/hold-order-for-approval/index.js |
| Action manifest | suggested package commerce-worklab (hyphen โ see Troubleshooting) |
Commerce event: observer.sales_order_save_commit_after ยท Fields: entity_id, grand_total
Cause: Commerce events not subscribed on the workspace, or the app was never re-associated and installed after deploy.
Fix: Add CloudIntegrationSDK and commerceeventing in Developer Console, then unassociate/re-associate and complete the Install step in App Management, then check Runtime logs for your action's activation.
runtimeActions package name for the event.
Cause: Commerce eventing config requires a hyphenated package name (e.g. commerce-worklab); an underscored name (commerce_worklab) is rejected for the eventing package specifically, even though the Step 2 webhook package can use one.
Fix: Register event-driven actions under a separate hyphenated package. It's fine for webhook and event action packages to coexist in the same ext.config.yaml.
Cause: Fields extracted from the wrong path โ the handler read params.data.entity_id instead of params.data.value.entity_id (CloudEvents wraps the Commerce payload one level deeper than a webhook payload).
Fix: Confirm the handler reads from params.data.value, and that entity_id/grand_total are declared in the event's fields array in app.commerce.config.ts.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
The re-association and Install step above showed an event subscription step for the order-hold handler and completed with no eventing errors.
In Adobe Developer Console โ your project โ Stage โ Events, confirm
a registration exists tied to your handler
(commerce-worklab/hold-order-for-approval or equivalent), or confirm
aio app build lists the action under that package.
Set Order approval threshold (USD) to 50 in the app's business
configuration. Place an
order with a grand total over $50. Wait ~30 seconds (event delivery is
asynchronous, up to ~59s for non-priority events).
In Commerce Admin โ Sales โ Orders, the order's status should be
On Hold (holded). An order placed under $50 should not be held.
Sync the order to a fake ERP and store the ERP ID in ABDB
Once an order exists, call a stand-in "ERP" service that returns a UUID
ERP reference id, and persist { order_id, erp_id } in an App
Builder Data Services (ABDB) collection so Step 5 below can look
it up per order.
When an order is saved, synchronize it to a fake ERP and remember the
returned ERP ID for later lookup by order ID.
Add a second runtime action to the existing observer.sales_order_save_commit_after
event registration used by the order-hold handler. The new runtime action
must run independently of the hold handler. Extract entity_id from the
event payload the same way (params.data.value.entity_id).
The handler must be idempotent and hold-aware:
1. Skip if the order is currently in "holded" state โ it has not been
approved yet.
2. Skip if an ERP sync record already exists for this order_id in the
erp-sync collection (do not re-sync on every save).
3. Otherwise, call the fake ERP endpoint (configurable as erp_sync_api_url,
default https://aisenseapi.com/services/v1/uuid, standing in for a real
ERP's ID generator) to obtain a UUID ERP ID. erp_id is a string, not a
number.
4. Persist to the erp-sync App Builder DB collection:
{ "order_id": <entity_id>, "erp_id": <uuid>, "synced_at": <ISO timestamp> }
The erp-sync collection must exist before the handler runs:
- Provision persistent App Builder Database Storage in region amer.
- Create the collection during app installation.
- Create a unique index on order_id during installation.
Register this as a non-web action with include-ims-credentials: true, both
on the sync action and the installation action. Follow the
commerce-app-storage skill for DB auth, installation steps, and
ext.config wiring. Do not use custom IMS credential normalization or
Core.AuthClient.generateAccessToken directly in the action โ let the skill
wire that.
Build and deploy when ready.
Re-associate and Install
Your agent already builds and deploys as the last step of the prompt
above, but that alone does not run the custom install step โ you still
have to manually re-associate and install the app before the
erp-sync collection exists: in Commerce Admin โ Apps โ App
Management, unassociate then re-associate the app with the same
Developer Console project/workspace, and click through the installation
wizard to Install. That Install step is what actually runs
scripts/setup-database.js and provisions the collection โ skipping it
leaves the handler deployed to Runtime with nowhere to write.
| Piece | Location |
|---|---|
| Event subscription | app.commerce.config.ts โ eventing.commerce[] (second runtime action on the Step 3 entry, not a new event) |
| Business config | erp_sync_api_url (default the fake UUID endpoint) |
| Database | app.config.yaml โ database.auto-provision: true, region: amer |
| Custom install step | scripts/setup-database.js โ creates erp-sync collection + unique index on order_id |
| Handler | src/.../actions/sync-order-to-erp/index.js |
Commerce event: observer.sales_order_save_commit_after (shared with Step 3 โ second runtime action, no Commerce-side rule โ the hold/dedupe checks live in the handler)
erp-sync collection is missing.
Cause: App Builder Data Services API not subscribed on the workspace, auto-provision: true missing from app.config.yaml, or the app was never re-associated and installed after deploy so the custom installation step never ran.
Fix: Subscribe the API in Developer Console, confirm the database block in app.config.yaml, redeploy, then unassociate/re-associate and complete the Install step in App Management so setup-database.js runs. Verify with aio app db collection list.
aio-lib-db init fails with an auth error inside the handler or install script.
Cause: include-ims-credentials: true is missing from the action's annotations, so no IMS token is injected.
Fix: Confirm annotations: include-ims-credentials: true on both the sync-order-to-erp action and the custom install action in ext.config.yaml. Rebuild and redeploy.
erp-sync document immediately, before anyone approved them.
Cause: The handler is missing the state === "holded" guard, or is checking status on a payload field that isn't populated by the declared event fields.
Fix: Add state to the event's fields array in app.commerce.config.ts and check it in the handler before syncing.
Check it works
0/5Expand a check for verification steps, or mark confirmed if you already know it passes.
The re-association and Install step above showed the custom installation step (database setup) completing successfully. There is no second event subscription step โ the ERP sync reuses the Step 3 event registration.
In Adobe Developer Console โ your project โ Stage โ Events, confirm
the existing registration for observer.sales_order_save_commit_after
now lists a second runtime action tied to the sync handler
(commerce-worklab/sync-order-to-erp or equivalent), alongside the Step 3
handler. Alternatively, confirm aio app build lists both actions under
that package.
aio app db collection list
Includes erp-sync.
Place an order under the Step 3 approval threshold so it is not held. Wait ~30โ60 seconds, then run:
aio app db document find erp-sync '{}'
You should see a document with order_id, erp_id, and synced_at.
Place an order over the threshold (held). Confirm no erp-sync
document is created for it until you manually release the hold in Admin
(Sales โ Orders โ Unhold) โ re-saving the order after unholding should
trigger the sync on the next sales_order_save_commit_after event.
Display ERP ID via an Admin UI SDK page
Give merchandisers a way to see the ERP ID that Step 4 wrote into the
erp-sync ABDB collection, without leaving Commerce Admin. The outline
calls for this "in the order grid," but the literal native-grid mechanism
and the App-Builder-native mechanism are two different things, and this
workshop's app is built purely through App Builder.
Add a Commerce Admin page โ reachable from the Admin menu โ that looks up
the ERP sync record for an order by order number, reading from the same
erp-sync ABDB collection Step 4 populated. This is the closest
App-Builder-native equivalent to "show the ERP ID in Admin" available to an
ACCS-only extension.
Add a Commerce Admin UI SDK page to this App Builder app that lists ERP
sync status for orders.
Register an Admin UI SDK extension point and a menu entry (label "ERP Sync
Status") that opens a page inside Commerce Admin. Check the App Management
skills installed in this project for the exact Admin UI SDK skill/plugin
name and manifest keys โ this repo's existing skills (commerce-app-webhooks,
commerce-app-eventing, commerce-app-business-config, commerce-app-storage)
don't cover Admin UI SDK, so confirm the correct one before scaffolding.
Add a protected (require-adobe-auth: true) runtime action, e.g.
get-erp-sync-status, that:
- Reads all documents from the erp-sync App Builder DB collection
(order_id, erp_id, synced_at), most recent first.
- Accepts an optional order_id query parameter to filter to a single order.
The Admin UI SDK page should render a simple table (Order ID | ERP ID |
Synced At) fed by that action, plus a search box that filters by order
number/order_id.
Gate the menu entry with an ACL resource declared for this extension (for
example Worklab_Erp::erp_sync_status) so only Commerce Admin roles granted
that resource in System > Permissions > User Roles see it โ confirm the
exact manifest key for declaring an ACL resource against your installed
Admin UI SDK skill/plugin, since it is not demonstrated in this project's
other skills.
Build and deploy when ready.
Re-associate and Install
Your agent already builds and deploys as the last step of the prompt above, but that alone does not register the new extension point on Commerce โ you still have to manually re-associate and install the app before the menu entry and ACL resource exist: in Commerce Admin โ Apps โ App Management, unassociate then re-associate the app with the same Developer Console project/workspace, and click through the installation wizard to Install. That Install step is what actually registers the Admin UI SDK page's menu entry and ACL resource on the Commerce instance โ skipping it leaves the page deployed to Runtime but absent from the Admin menu, independent of the separate Admin UI SDK toggle described below.
| Piece | Location |
|---|---|
| Admin UI SDK extension point + menu | wherever your Admin UI SDK skill scaffolds it (confirm path/skill โ see caveat above) |
| Read action | src/.../actions/get-erp-sync-status/index.js (protected, require-adobe-auth: true) |
| Data source | erp-sync ABDB collection (from Step 4 above) |
| ACL gate | resource ID declared in the extension's manifest, assigned via System โ Permissions โ User Roles |
aio runtime action list, but nothing shows up in Commerce Admin.
Cause: The Admin UI SDK setting itself has to be switched on in Commerce Admin configuration โ declaring the extension point in your app does not turn it on for the instance. This is a console/Admin toggle, not something an agent can flip for you.
Fix: In Commerce Admin, enable the Admin UI SDK for this app's extension, then reload the Admin menu.
Cause: The menu is very likely rendering correctly โ just not where you're looking. adminUi.menu.parentMenu determines which existing Commerce Admin section (Sales, Catalog, Customers, Marketing, Content, Reports, Stores, or System) the entry nests under; there is no "Apps" option, and a custom menu never appears as its own top-level nav icon. It's easy to spend a long time re-checking registration, ACL, and caching when the actual gap is just not having expanded the right flyout.
Fix: Before troubleshooting anything else, find the exact parentMenu constant your agent used in app.commerce.config.ts (e.g. MENU_REPORTS), then expand that specific section in the Admin left nav. The entry shows up grouped under your app's display name inside that section's flyout menu, alongside that section's built-in items.
Cause: No ACL resource was declared for the menu entry, or the declared resource isn't actually being checked by the page/menu registration.
Fix: Confirm the extension declares an ACL resource ID and that it is referenced by the menu entry. Assign that resource explicitly to intended roles in System โ Permissions โ User Roles โ an ungated Admin UI SDK entry defaults to visible for any role that can reach the parent menu.
aio app db document find.
Cause: The read action isn't using include-ims-credentials/the storage skill's DB auth pattern, so its DB connection silently fails or reads from the wrong scope.
Fix: Follow the same DB-auth wiring used for the sync-order-to-erp action in Step 4 above.
Uncaught TypeError: (0 , _jsxDevRuntime.jsxDEV) is not a function. This happens even with the untouched, auto-generated app.tsx scaffold โ it is not caused by custom page code.
Cause: This is a known issue in certain versions of aio app build/aio app deploy's Parcel-based bundling, not a bug in your page code. A permanent fix is on the Admin UI SDK's backlog; in the meantime, the workaround below routes JSX through Babel instead.
Fix: Force Parcel to route JSX through Babel (in production mode) instead of its default SWC transform:
1. npm install -D @babel/core@^7 @babel/preset-react@^7
2. Add src/commerce-backend-ui-2/web-src/.babelrc:
json { "presets": [["@babel/preset-react", { "runtime": "automatic", "development": false }]] }
3. rm -rf dist .parcel-cache && aio app build, then redeploy.
Check it works
0/5Expand a check for verification steps, or mark confirmed if you already know it passes.
The re-association and Install step above completed without error and registered the new Admin UI SDK page and ACL resource on the Commerce instance.
In Commerce Admin, the Admin UI SDK setting for this app is switched on โ this is the most common "the agent's code doesn't work" report for this step: the page can be deployed and correct, yet invisible until this is enabled.
A new menu entry ("ERP Sync Status" or whatever label your agent used) appears in Commerce Admin for a role granted the declared ACL resource, and does not appear for a role that lacks it.
Opening the page lists ERP sync records with order_id, erp_id, and
synced_at matching what
aio app db document find erp-sync '{}'
returns directly.
Searching by an order number that was held (Step 3 above) and never approved returns no ERP sync row โ consistent with the sync handler's hold-aware skip logic.
๐งฑ Extend Storefront
EDS Basics, Blocks, Slots, and a Custom Category Page
This lab starts with the fundamentals, then extends them โ and the whole
lab builds one real artifact: a Back to School landing page for the
Office & Paper category, at /back-to-school. Steps 1โ3 build Edge Delivery Services vocabulary from scratch,
one exercise at a time: author the page, add an existing block for its
hero banner, and add the live product grid backed by a drop-in. Steps 4โ6
then put that vocabulary to work. Adobe Commerce Storefront has two extension mechanisms:
extend a drop-in via its named slots (lowest effort, no new
component), or create a new Edge Delivery block for standalone
content merchandisers place on a page. Step 4 uses slot extension to inject
a manual-approval disclaimer into the Product List Page drop-in from Step
3. Step 5 covers the other mechanism: building a new block from scratch.
Step 6 turns that block into a shopper-driven, shareable comparison bar.
Author your first page as a document
Every page in this storefront starts as a plain document โ the same kind of heading/paragraph/image content you already know how to write. This lab's goal is a real artifact: a Back to School landing page for the Office & Paper category. Create the document from scratch to see the whole chain: document in, live page out.
Prerequisites: the storefront is cloned and running (
npm start), and AEM Sidekick is installed and attached to it โ both assumed done in Tooling and Provisioning.
- In da.live's Browse view, create a new document at
/back-to-schoolin your storefront's site โ this becomes your custom Office & Paper category landing page. - Add a top-level heading, a short paragraph of body copy, and an image.
- Add a metadata block: a two-column table headed
Metadata, with rowsTitleandDescription. This is the one block every EDS document can carry that isn't visible content โ it sets the page's browser tab title and meta description instead of rendering inline. - Click the Preview button in da.live's own toolbar โ it builds the
real page at its
.aem.pageURL, which is exactly the document's path (/back-to-schoolin,/back-to-schoolpage out โ a one-to-one mapping)..aem.pageis a staging copy: only you (and anyone with the link) can see it, and it always reflects the document's latest saved version. - Click Publish in Sidekick to push that same content to the
.aem.liveURL โ the production copy real shoppers see. Preview and Publish are independent actions on independent copies: publishing isn't automatic just because you previewed, and previewing again later doesn't touch what's already live.
Cause: The image was pasted in as a link/URL instead of an actual embedded image in the document.
Fix: Insert the image directly into the document (drag-and-drop or the editor's image-insert action) rather than pasting a link โ EDS only turns an embedded image into a picture element, not a text link.
Cause: The metadata table's rows don't use the expected key/value shape (a Title row and a Description row under a Metadata header), or the document wasn't saved before previewing.
Fix: Match the exact two-column Metadata table shape, save the document, and re-run da.live's Preview.
Cause: Preview and Publish are two separate actions โ Preview only updates the staging (.aem.page) copy; Publish is required to push to production (.aem.live).
Fix: Click Publish in Sidekick, not just Preview, and give edge propagation a few seconds before reloading the production URL.
Check it works
0/5Expand a check for verification steps, or mark confirmed if you already know it passes.
The document you created at /back-to-school renders at a page whose URL
path is also /back-to-school โ no separate routing or mapping step in
between.
da.live's Preview button opens the page and shows the heading, paragraph, and image you authored.
Open the previewed page's DevTools Elements panel (or View Source).
Find your heading rendered as an h1, your paragraph as a p, and your
image wrapped in a picture element โ all inside one wrapping section
div. There's no extra nested-div soup beyond that single section
wrapper: the semantic HTML mirrors the structure you authored.
The browser tab shows the Title value you authored in the metadata
block, not a generic/default title.
After clicking Publish, the production (.aem.live) URL shows the same
content as Preview.
Add an existing block and learn its anatomy
A block is how a document asks for something more than a heading, paragraph, or image โ a merchandiser authors a table, and code decides what it becomes. Add one to see both halves of that contract.
On the same
/back-to-schooldocument, add a Hero block: insert a table headedHero, with a row containing a headline, a short line of subcopy, and an image โ e.g.Back to School starts here./Notebooks, folders, and everything they'll actually use.Publish the document and open the live page โ the
Herotable renders as a styled hero section (background image, large heading, positioned copy), not as a plain table.In your locally cloned storefront repo, open
blocks/hero/hero.jsandblocks/hero/hero.cssside by side with what you just authored.Rename the block's header to
Hero (dark). Text in parentheses after a block name becomes extra CSS classes on the block โ a block variation: no code change needed to opt a specific instance into different styling. Publish and inspect the block in DevTools: its wrapper now carries bothheroanddarkclasses.Hero doesn't ship with a built-in
darkstyle, so that class alone won't change anything yet. Add one yourself to see the variation take effect: append toblocks/hero/hero.cssCSS.hero.dark picture { background-color: var(--color-neutral-900); } .hero.dark p { color: var(--color-neutral-50); }
| Piece | Location |
|---|---|
| Hero content | a Hero block table on /back-to-school, authored in da.live |
| Block code (read-only for this step) | blocks/hero/hero.js โ decorate(block) |
dark variation styling |
blocks/hero/hero.css โ .hero.dark picture (or filter for an opaque image) and .hero.dark p |
How a block works: every block is a folder of two files named after
the block ({name}.js + {name}.css). scripts/aem.js scans the page
for a matching table header (Hero) and turns each row into a nested
div (row โ div, cell โ div) automatically, before any block code
runs. It then calls that block's decorate(block), passing in that raw
nested-div element, and the block's own JavaScript rewrites it in place
into whatever markup it needs. The block's CSS is scoped to the block's
own class (e.g. .hero), so it only ever styles its own markup.
Design tokens: var(--color-neutral-900) and var(--color-neutral-50)
above are design tokens โ named CSS custom properties defined once
in styles/styles.css under :root (a --color-neutral-50 through
--color-neutral-900 scale, plus spacing, typography, and shape/border
tokens). Per Adobe's own docs, "drop-in components and custom blocks
both use the CSS custom properties (design tokens) defined in the
styles/styles.css file" โ the same set backing this Hero block's
dark variation also styles every drop-in-backed block later in this
lab. Override a token globally in styles/styles.css, or scope it to
one component by re-declaring it on that component's own class (e.g.
.cart-cart-summary-grid { --spacing-small: 16px; }) โ either way, one
change propagates everywhere the token is used, instead of hunting down
hardcoded values block by block.
Cause: The table's header row text doesn't exactly match the block's expected name (Hero), so scripts/aem.js never recognized it as a block.
Fix: Confirm the table's header row reads exactly Hero (case and spelling matter) and republish.
Hero (dark) has no visible effect at all, even after adding the CSS rule.
Cause: The variation text must be inside parentheses directly after the block name in the table header โ a typo or missing parentheses means scripts/aem.js never adds the extra class.
Fix: Confirm the header reads exactly Hero (dark) and that hero.css was saved and rebuilt (npm start) after adding the .hero.dark picture rule.
Cause: background was set on .hero or .hero.dark instead of .hero.dark picture. hero.css positions picture with z-index: -1 inside .hero, so an opaque background on .hero itself paints over the image and hides it.
Fix: Target picture specifically (.hero.dark picture { background-color: var(--color-neutral-900); }), not the block wrapper's own background.
.hero.dark picture { background-color: ... } has no visible effect at all, even targeting picture correctly.
Cause: The Hero image itself is a fully opaque photo with no transparent background โ there's nothing for a background color to show through.
Fix: Either replace the image with one that has a real transparent background, or use filter: brightness(0.4) instead, which darkens the image directly regardless of transparency.
Cause: The subcopy paragraph has no color override in hero.css and inherits the default body text color, var(--color-neutral-800) โ a dark gray with poor contrast against a dark background. Only the heading has its own override (var(--background-color), white).
Fix: Add .hero.dark p { color: var(--color-neutral-50); } alongside the background rule, so the dark variant overrides subcopy color the same way hero.css already overrides the heading's.
npm start) but not on the published page.
Cause: Same Edge Delivery caching/propagation behavior as Step 1 โ the page needs to be republished from Sidekick and given time to propagate.
Fix: Republish (Preview โ Publish) and re-check after a few seconds before assuming the block itself is broken.
Check it works
0/6Expand a check for verification steps, or mark confirmed if you already know it passes.
The live page shows a designed hero section โ not a two-column table of plain text.
In DevTools, inspect the rendered Hero markup and find the headline,
subcopy, and image you authored โ decorate() restructured the markup,
but didn't invent new content.
decorate() doing the work
In blocks/hero/hero.js, identify the line(s) inside decorate(block)
that read the block's children and rebuild them into the final hero
markup.
Renaming the block to Hero (dark) adds a dark class to the block's
wrapper in the DOM (visible in DevTools) โ no edit to hero.js required.
After adding your rules to hero.css, a dark background shows through
the image's transparent areas (or the image renders visibly darker, if
you used filter instead) whenever the block name includes (dark),
and reverts to the default look when it doesn't โ without hiding the
image.
With .hero.dark p in place, the subcopy text ("Notebooks, folders, and
everything they'll actually use." or whatever you authored) is clearly
readable against the dark background โ
not the same dark gray as the background, which is what happens without
that rule.
Add a commerce block backed by a drop-in
Blocks aren't the only way content becomes UI โ some blocks mount pre-built, shared components called drop-ins instead of hand-built markup. Add one to the same page to see the difference.
On the same
/back-to-schooldocument, add a Product List Page block. Like any Commerce block, it's a two-column table โ property names on the left, values on the right. This is what actually makes/back-to-schoola category page instead of a plain document: pointurlpathat Office & Paper's real category path from your own catalog (find it in Commerce Admin's Catalog โ Categories, or copy it from the storefront's own nav bar โ likely something close to the example below, depending on how your catalog is organized):Product List Page urlpath office/paper Publish and open the live page โ a real product grid renders for the Office & Paper category (images, names, prices), pulled live from the catalog, not authored content. This is the actual category page you're building, not a preview or a mockup.
In the browser DevTools console on that page, run:
JSDROPINS.showOverlays(true);This draws labeled outlines over the drop-in containers the block mounts (results grid, sort, pagination) and any named slots they expose.
In your locally cloned storefront repo, open
blocks/product-list-page/product-list-page.jsand find its call toprovider.render(SearchResults, { ... })(block)โ the main container behind the grid you just saw render.
| Piece | Location |
|---|---|
| Office & Paper product grid | a Product List Page block table on /back-to-school, authored in da.live |
urlpath config |
the block's authored row, read via readBlockConfig(block) |
| Drop-in code (read-only for this step) | blocks/product-list-page/product-list-page.js โ SearchResults container |
How this differs from Step 2's Hero block: Hero is pure Edge Delivery
โ decorate() only rearranges the markup you authored. A drop-in-backed
block like this one also imports a @dropins/storefront-* package,
mounts one of its pre-built containers (SearchResults, plus
SortBy/Facets/Pagination), and fetches live commerce data through
config.json's commerce-endpoint โ the authored table only configures
it (which category to show), it doesn't supply the content.
Same design tokens, one more consumer: the tokens from Step 2 aren't
just for custom blocks โ Adobe's own docs confirm "drop-in components
and custom blocks both use the CSS custom properties (design tokens)
defined in the styles/styles.css file," so SearchResults renders
with the same --color-*/--spacing-* scale the Hero's dark variant
used. Rebrand the storefront by editing the tokens once, and both plain
EDS blocks and drop-in-backed ones pick up the change.
Cause: The table's header row text doesn't exactly match the block's expected name (Product List Page), so it was never recognized as a block.
Fix: Confirm the table's header row reads exactly Product List Page (case and spelling matter) and republish.
Cause: The authored urlpath doesn't match a real category path in the catalog โ a typo, or the category has no products marked visible in Catalog/Search.
Fix: Re-copy the category's URL key exactly from Commerce Admin's Catalog โ Categories (or the storefront's nav bar) rather than guessing the path.
DROPINS is not defined in the console.
Cause: The DROPINS debug global is only exposed in a local/dev build, not a minified production build.
Fix: Run the overlay check against your local npm start server, not the published production URL.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
The live /back-to-school page shows product cards (image, name,
price) for the category you authored โ not a plain table or placeholder
text.
Running DROPINS.showOverlays(true) outlines the SearchResults
container (and any of SortBy/Facets/Pagination present) on the
page.
Every product shown belongs to the category you authored in urlpath โ
not the full catalog.
From product-list-page.js, name the container that renders the product
grid (SearchResults).
Show a manual-approval disclaimer above the product grid
Warn shoppers, above the product grid you built in Step 3, that orders over the approval threshold set in Extend Business Logic's Step 3 will be held for manual review before shipping.
Add a manual-approval disclaimer above the product grid on the storefront's
Product List Page block using drop-in slot extension, not a new block.
In blocks/product-list-page/product-list-page.js, the block mounts the
SearchResults container from @dropins/storefront-product-discovery. It
already passes a slots object with ProductImage and ProductActions --
leave those alone, and add a Header entry alongside them:
slots: {
ProductImage: (ctx) => { /* existing, unchanged */ },
ProductActions: (ctx) => { /* existing, unchanged */ },
Header: (ctx) => {
const el = document.createElement('div');
el.className = 'approval-disclaimer';
el.textContent =
'Orders over $500 are reviewed and manually approved before they ship.';
ctx.appendChild(el);
},
}
Read the dollar amount from an authorable field on the block (a table row
in the authored document, e.g. "Approval Threshold") instead of hardcoding
it, so a merchandiser can change it in da.live without a redeploy. Fall
back to 500 if the field is blank.
Style the .approval-disclaimer element as a visible but non-blocking notice
(not an error state) โ a bordered callout, not a red banner, since this is
informational and does not block browsing or checkout.
This is a general policy notice, not tied to the shopper's current cart โ
always show it once the configured/authored threshold is available. Note
in a code comment, directly above the authored-field read, that this
dollar amount is a workshop-simplified stand-in for the real
order_approval_threshold business-config value from Extend Business
Logic, and that the two are not wired together yet โ see the API
Orchestration page's optional stretch step for the fix.
Build and verify locally with npm start.
| Piece | Location |
|---|---|
| Grid disclaimer | blocks/product-list-page/product-list-page.js โ SearchResults Header slot |
| Authorable threshold | a table row/field on the Product List Page block, edited in da.live or Universal Editor |
| Styling | .approval-disclaimer rule added to the block's CSS |
Slot API used: each slot context (ctx) exposes appendChild,
prependChild, replaceWith, appendSibling, remove, getSlotElement,
onChange, and dictionary. Best practice from the source material: build
the DOM once at mount, then react to data changes in onChange rather than
re-rendering the whole slot.
Known simplification: the authored "Approval Threshold" field and Extend Business Logic's
order_approval_thresholdbusiness-config value are two independent sources of truth for the same number, and nothing here keeps them in sync. A merchandiser could edit the da.live field to$750while the backend still holds orders over$500โ the disclaimer would then actively mislead a shopper about what actually happens to their order. This is intentional for the exercise: at this point in the curriculum you haven't built a runtime action or an API Mesh yet, so there's no live value to fetch. Once API Orchestration has stood up the API Mesh (which teaches exactly that pattern via the tracking ID), its optional stretch step properly fixes this by replacing the authored field with a live fetch of the realorder_approval_threshold, making App Builder config the single source of truth.
/back-to-school, even though the block code looks correct.
Cause: The slot object was passed to the wrong container, or the block's decorate() never awaited provider.render(...) before appending to ctx.
Fix: Confirm the block imports SearchResults from @dropins/storefront-product-discovery/containers/SearchResults.js and that provider.render(SearchResults, { slots: {...} })(block) is awaited, matching the pattern used for other drop-in slot customizations in this storefront.
Header slot made the existing product grid disappear or stop rendering images/add-to-cart buttons.
Cause: The ProductImage/ProductActions entries already in the block's slots object were replaced instead of kept alongside the new Header entry.
Fix: Confirm all three slot entries (ProductImage, ProductActions, Header) are present in the same slots object passed to provider.render(SearchResults, ...).
Cause: Edge Delivery caches config and content at the edge (up to ~2 hours max-age); the change hasn't propagated, or the page wasn't republished.
Fix: Republish the page from Sidekick (Preview โ Publish) and wait for propagation rather than assuming the block logic is wrong. This is a known day-two "config seems to not apply" pattern for Edge Delivery storefronts, not specific to this block.
(no definition) in Universal Editor and can't be edited visually.
Cause: The block has no _<name>.json Universal Editor model โ this is only required for visual editing, not for the block to function.
Fix: If merchandiser-friendly visual editing of the threshold field matters for your engagement, add a _product-list-page.json (or equivalent) model fragment and run npm run build:json to merge it into the root component-*.json files.
Check it works
0/5Expand a check for verification steps, or mark confirmed if you already know it passes.
npm start
Runs the storefront locally; open /back-to-school โ the disclaimer
text renders above the product grid from Step 3, styled as an
informational callout (not an error).
DROPINS.showOverlays(true);
Run this in the browser DevTools console on /back-to-school โ it draws
labeled outlines over every drop-in container and slot on the page.
Confirm the SearchResults container and its Header slot are both
outlined, with the disclaimer sitting inside that slot's outline rather
than floating elsewhere in the DOM.
Edit the authored "Approval Threshold" field on the block (in da.live or Universal Editor) to a different dollar amount, republish, and confirm the disclaimer text updates without a code deploy.
Confirm the disclaimer does not block or interfere with browsing, sorting/filtering, or adding items to cart from the grid โ it is informational only, consistent with the pager's "soft gate, never a hard block" principle used elsewhere in this workbook.
npm run lint
The boilerplate's Husky pre-commit hook also runs build:json, so if
this block ever gets a Universal Editor model, verify that step doesn't
fail on your changes.
Build a Product Compare block from scratch
This step exercises the other mechanism: authoring a brand-new Edge Delivery block that isn't tied to any drop-in container at all โ a side-by-side product comparison table that a merchandiser drops into any page as a table in the authored document.
Rather than one big prompt, build this block the way the source article teaches it: one concept, and one small prompt, at a time.
Concept 1 โ Scaffold the block and read its authored config
Every block starts the same way regardless of what it does: two files, and
a decorate() that reads whatever the merchandiser typed into the
authored table.
Create a new Edge Delivery block, product-compare.
Files:
blocks/product-compare/product-compare.js
blocks/product-compare/product-compare.css
In decorate(block), read the SKUs to compare from the authored block table
using readBlockConfig(block) (from scripts/aem.js, the same helper other
blocks in this boilerplate use) -- expect a "SKUs" row whose value is a
comma-separated list, e.g. "ADB125,ADB160,ADB215". Trim and split on
commas; cap the list at 3 SKUs and log a console warning if more were
authored.
For now, just clear the block and render the parsed SKU list as plain
text so we can see the config is being read correctly. Don't fetch product
data or build the real table yet -- that's the next step.
Concept 2 โ Fetch data with a drop-in API call (Level 1)
The source material's abstraction ladder: raw CS_FETCH_GRAPHQL queries
give full control but the most query-maintenance burden; a pre-built
drop-in API function like search() gives you the same data with none of
that maintenance. Reach for the API call before the raw query.
In blocks/product-compare/product-compare.js, replace the placeholder
SKU-list text with a real product fetch, using the Product Discovery
drop-in's search() function rather than a raw GraphQL query:
import { search } from '@dropins/storefront-product-discovery/api.js';
const { items } = await search({
filter: [{ attribute: 'sku', in: skus }],
pageSize: skus.length,
});
Handle the case where search() returns fewer products than SKUs requested
(a typo'd or delisted SKU) -- keep track of which requested SKUs came back
with no matching item, we'll render a "not found" state for those next.
Still render the result as plain text for now (e.g. one line per returned
product's name and price) -- the real table markup is the next step.
Concept 3 โ Render with drop-in SDK components (Level 2)
The top of the abstraction ladder: don't hand-build image/price markup. Mount the same shared components the rest of the storefront's PDP/PLP already use, so this block's formatting and accessibility stay consistent for free.
Replace product-compare's plain-text output with a real <table>: one
column per product, one row per attribute (image, name, price, and any
attribute names found in the authored config under an optional
"Attributes" row, also comma-separated).
For the image and price cells specifically, don't build raw <img>/price
markup yourself -- mount the shared SDK components:
import { Image, PriceRange } from '@dropins/tools/components.js';
await UI.render(Image, { src: product.images[0]?.url, alt: product.name,
params: { width: 300 } })(imageCell);
await UI.render(PriceRange, { currency: product.price.currency,
minimumAmount: product.price.final, maximumAmount: product.price.final
})(priceCell);
(Confirm the exact prop names against whichever @dropins/tools version is
pinned in this project's package.json before treating this shape as
final -- it isn't given to you here.)
For any SKU that came back with no matching product (from the previous
step), render a "not found" cell in its column instead of leaving it
blank or throwing.
Build and verify locally with npm start.
| Piece | Location |
|---|---|
| Block markup + fetch logic | blocks/product-compare/product-compare.js |
| Block styling | blocks/product-compare/product-compare.css |
| Authored SKU list | a "SKUs" (and optional "Attributes") row on the block's table, edited in da.live |
| Product data | search() from @dropins/storefront-product-discovery/api.js, filtered by SKU |
| Image / price rendering | Image / PriceRange from @dropins/tools/components.js |
Abstraction levels recap: Concepts 1โ3 above walked the source
material's ladder in order โ authored config first, then a drop-in API
call (search()), then drop-in SDK components (Image/PriceRange).
Reach for the highest rung that covers what you need before dropping to a
raw CS_FETCH_GRAPHQL query.
Note: this exercise is based on Adobe's "Build your own Commerce storefront block" article (see Resources below) โ read it for more detail on the full Product Compare feature this step is adapted from.
search is not a function or an import error on @dropins/storefront-product-discovery/api.js.
Cause: The Product Discovery drop-in isn't installed/registered in this project, or the import path doesn't match the pinned dropins version.
Fix: Check package.json for the installed @dropins/storefront-product-discovery version and confirm the export path against its own package.json exports map before assuming the prompt's path is exact.
NaN.
Cause: PriceRange was given the wrong prop shape โ the real prop names may differ from minimumAmount/maximumAmount depending on the dropins version.
Fix: Inspect an existing PDP/PLP block in this storefront that already renders a price with PriceRange and match its exact prop shape instead of guessing.
Image cells.
Cause: commerce-assets-enabled is set to false in config.json, so the storefront isn't resolving product images through the Commerce/AEM Assets image pipeline Image expects.
Fix: Check config.json and confirm commerce-assets-enabled is true before assuming the block's Image usage is wrong.
Cause: Same Edge Delivery caching behavior called out in Step 4 โ the page needs to be republished from Sidekick and given time to propagate.
Fix: Republish (Preview โ Publish) and re-check after propagation before assuming readBlockConfig() is broken.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
npm start
On the same /back-to-school document, add a product-compare block
table containing 2โ3 real SKUs from your catalog โ e.g.
ADB125,ADB160,ADB215 โ publish it, and open
the page โ a table renders with one column per SKU, each showing an
image, name, and price pulled live from the catalog (not placeholder
text).
Add a SKU that doesn't exist in the catalog to the authored table. Confirm that column renders a "not found" cell instead of breaking the whole block or throwing an unhandled error in the console.
Confirm the image and price cells use the same drop-in component styling (fonts, price formatting, currency symbol) as the PDP/PLP elsewhere in the storefront, rather than custom one-off markup.
npm run lint
Turn it into a live Product Compare bar
Step 5's product-compare block only compares a fixed, merchandiser-authored
SKU list. This step makes the comparison shopper-driven instead: an "Add to
compare" affordance on product cards, a persistent bar that tracks the
running selection, and a shareable URL โ the full feature from the source
article, minus the parts already built in Step 5.
Same approach as Step 5: one concept, one small prompt, at a time.
Concept 1 โ Emit an event instead of coupling two blocks together
Two blocks that both need to react to "a shopper picked a product" shouldn't import each other directly. The storefront's shared Event Bus lets the product listing announce a selection without knowing who (if anyone) is listening.
In blocks/product-list-page/product-list-page.js -- the same block you
added to /back-to-school in Step 3 -- find wherever product cards render
and add an "Add to compare" button. On click, emit an event on the
storefront's shared Event Bus instead of calling any other block's code
directly:
import { events } from '@dropins/tools/event-bus.js';
events.emit('compare/products', { sku, name, image });
Don't build the listener or the bar UI yet -- just get the button emitting
the event with the right payload shape. Log the emitted payload to the
console for now so we can confirm it in the next step.
Concept 2 โ Listen for the event and build the bar block
A brand-new block, product-compare-bar, subscribes to that same event and
renders the running selection -- this is the "listener" half of the
pub/sub pair. Following the source article, the bar doesn't hardcode which
page it sends the shopper to when they're ready to compare -- that
redirect target is itself an authored block param, so a merchandiser can
point it at whichever page hosts a product-compare block โ here, that's
/back-to-school itself, from Step 5.
Create a second block:
blocks/product-compare-bar/product-compare-bar.js
blocks/product-compare-bar/product-compare-bar.css
In decorate(block), read the redirect target for the bar's "Compare"
button from the block's own authored table using readBlockConfig(block) --
the same helper used for product-compare's "SKUs" row in Step 5 -- expecting
a "Compare Page" row whose value is the path to the page that hosts the
product-compare block, e.g. "/back-to-school". Log a console warning
and skip rendering the button (rather than linking to a guessed path) if
the row is missing or blank.
Subscribe to the event emitted in the previous step, using the same Event
Bus import:
import { events } from '@dropins/tools/event-bus.js';
events.on('compare/products', (product) => {
// add to a running in-memory list, capped at 3 entries
});
Render the bar as a small fixed strip listing the selected products (image
+ name), with a way to remove one from the selection, plus a "Compare (N)"
link/button pointing at the authored redirect target. Cap the list at 3
products -- matching product-compare's column limit from Step 5 -- and
ignore/no-op additions past that cap rather than erroring.
Don't worry about carrying the selection over in the button's URL yet --
that's the next step.
Concept 3 โ Persist the selection to the URL and wire it back into Step 5
A selection that only lives in memory disappears on refresh and can't be
shared. Push it into the URL, then teach Step 5's product-compare block
to read that same URL state.
In product-compare-bar.js, persist the running selection to the URL as a
comma-separated query param whenever it changes, so the comparison is
shareable and survives a refresh:
const url = new URL(window.location);
url.searchParams.set('compare', skus.join(','));
window.history.replaceState({}, '', url);
Remove a SKU from the URL param (not just the in-memory list) when the
shopper deselects it from the bar.
Update the "Compare" button built in the previous step so its target also
carries the current selection, e.g.
`${comparePagePath}?compare=${skus.join(',')}` -- clicking it should land
the shopper on the authored compare page with today's selection already
applied.
Then update blocks/product-compare/product-compare.js from Step 5 so it
prefers the `compare` query param over its authored "SKUs" row when the
param is present, falling back to the authored list otherwise -- so the
same block serves both the merchandiser-curated case (Step 5) and the
shopper-driven case (this step).
Build and verify locally with npm start.
| Piece | Location |
|---|---|
| Compare bar block | blocks/product-compare-bar/product-compare-bar.js |
| "Add to compare" affordance | the product-list-page.js product cards, on /back-to-school from Step 3 |
| Cross-block wiring | events from @dropins/tools/event-bus.js |
| Redirect target | authored "Compare Page" row on product-compare-bar's block table โ /back-to-school |
| Shareable state | compare query param, read by both blocks |
events.on in product-compare-bar.js never fires when a card's "Add to compare" button is clicked.
Cause: The two blocks are importing separate Event Bus instances, or the click handler was wired before events was imported/available.
Fix: Confirm both blocks import events from the exact same @dropins/tools/event-bus.js path (a copy-paste from a different dropins version can resolve to a different module instance).
Cause: The product-compare-bar block's authored "Compare Page" row is missing, blank, or doesn't match /back-to-school (where Step 5's product-compare block actually lives).
Fix: Check the "Compare Page" row on the bar's authored table in da.live, and confirm the block logs a warning (rather than guessing a path) when that row is empty.
Cause: The product-compare block's query-param check either runs before the URL is updated, or wasn't added at all.
Fix: Re-check the Step 5 update described in the prompt above โ the compare param must be read and preferred over the authored config before falling back to it.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
npm start
On the same /back-to-school document, add a product-compare-bar
block table with a Compare Page row set to /back-to-school (no other
rows needed), publish, then click "Add to compare" on two or three
product cards in the Step 3 grid โ the bar appears (or updates)
immediately with each selection, without a page reload.
Confirm the address bar's ?compare=... query param updates as products
are added/removed, and that reloading the page with that URL restores the
same selection in the bar.
Click the bar's "Compare" button โ since "Compare Page" points back at
/back-to-school itself, it reloads the same page with the selection in
the URL, and Step 5's product-compare block now renders those SKUs
instead of its authored "SKUs" row.
๐ธ๏ธ API Orchestration
API Mesh and Tracking ID
Put API Mesh between the storefront and a new App Builder runtime
action: the action generates a fake tracking ID for each order (mirroring
the fake-ERP pattern from Extend Business Logic), Mesh stitches it onto the
Commerce order schema, and the storefront's My Account page displays it โ
no separate REST call from the browser, no CORS configuration on the
runtime action itself. The mesh mechanics below (sources, transforms,
additionalResolvers) are pulled directly from
commerce-practice-builder/training/workbook-api-mesh.html. Step 3 is an
optional stretch goal that reuses this same public-action-plus-mesh pattern
to fix a known gap from Extend Storefront's Step 4.
Wire API Mesh between the storefront and a tracking-ID action
Add a runtime action that generates and persists a tracking ID per order,
expose it as a Mesh source, and stitch a trackingId field onto the order
type the storefront already queries.
Concept 1 โ Build and deploy the tracking-ID action
Get a real, callable HTTP endpoint deployed on its own first โ API Mesh's
openapi source handler needs a live URL to describe, not a plan for one.
Add a runtime action, generate-tracking-id, to this App Builder app.
Given an order_id (or order increment number), the action must:
1. Check the tracking-ids App Builder DB collection for an existing record
for this order โ if found, return it (idempotent, same pattern as the
erp-sync collection from Extend Business Logic).
2. Otherwise generate a random tracking ID (reuse the same style of fake
stand-in service used elsewhere in this lab, e.g. a random-number/string
generator) and persist { order_id, tracking_id, generated_at } to the
tracking-ids collection, with a unique index on order_id created during
app installation (same customInstallationSteps pattern as erp-sync).
3. Return { order_id, tracking_id } as JSON from a GET request, e.g.
GET /generate-tracking-id?order_id=<id>.
Make this a web action (web: true) since API Mesh's openapi source handler
will call it directly over HTTP โ do not require Adobe auth on it, since
Mesh (and eventually the storefront) calls it anonymously; keep it narrow
(read/generate only, no write access to orders).
Use the commerce-app-storage skill for the DB wiring, consistent with the
erp-sync collection's installation pattern.
Build and deploy this action before moving on โ the next step needs a real
deployed endpoint to point API Mesh's OpenAPI source at.
Concept 2 โ Wire it into API Mesh
With the action live, stitch its response onto the Commerce order type the
storefront already queries, so trackingId shows up as an ordinary field
on that type instead of a separate call the storefront has to make itself.
Scaffold or update this project's API Mesh mesh.json to expose the
generate-tracking-id action deployed in the previous step:
- Add a source named "Tracking" using the openapi handler, pointing at a
minimal OpenAPI document describing the generate-tracking-id action's GET
endpoint (path, order_id query param, 200 response schema
{ order_id, tracking_id }). Runtime actions don't publish an OpenAPI spec
automatically โ write a small static one and reference it from the source
handler's "source" field.
- Add additionalTypeDefs extending the Commerce order type the storefront's
My Account Order History/Order Details view already queries with:
trackingId: String
Confirm the exact order type name (commonly CustomerOrder in Commerce's
GraphQL schema) via schema introspection against your Commerce source
before wiring the resolver โ do not assume the type/field names without
checking.
- Resolve trackingId with a *programmatic* resolver (a JS file referenced
from additionalResolvers), not the declarative additionalResolvers
object shorthand. The Tracking source's generateTrackingId query returns
an object ({ order_id, tracking_id }) while trackingId itself is a plain
String -- that scalar-target/object-source combination triggers a known
API Mesh bug ("No type was found for field node __typename") in the
declarative form, because there's no client-side sub-selection on a
scalar field for Mesh to forward to the source query. A programmatic
resolver sidesteps this by passing an explicit selectionSet on the call:
// mesh/additional-resolvers.js
module.exports = {
resolvers: {
CustomerOrder: {
trackingId: {
selectionSet: '{ number }',
resolve: async (root, args, context, info) => {
const result = await context.Tracking.Query.generateTrackingId({
root, args: { order_id: root.number }, context, info,
selectionSet: '{ tracking_id }',
});
return result?.tracking_id ?? null;
},
},
},
},
};
Reference it from mesh.json's additionalResolvers array as
["./mesh/additional-resolvers.js"] (a file path, not the declarative
object array).
- Add (or update) a top-level responseConfig.CORS.origin array so the
storefront can call this mesh directly from the browser once Step 2
points config.json at it: include http://localhost:3000 (local
storefront dev server) and the deployed storefront's exact origin. Do
not use "*" if credentials: true is also set anywhere in the config --
browsers reject that combination.
- Check the existing Commerce source's operationHeaders and add (if
missing) a mapping that forwards the client's incoming Authorization
header through to Commerce, e.g.
"Authorization": "{context.headers.authorization}". Without this, API
Mesh does not forward it by default: generateCustomerToken will still
return a valid token (it doesn't need auth itself), but every
authenticated query made with that token afterwards (customer, order
history, etc.) will fail with a "not authorized" error once routed
through the mesh -- which is what Step 2's My Account view needs to work.
Run aio api-mesh:update mesh.json and aio api-mesh:status until it reports
success.
| Piece | Location |
|---|---|
| Tracking action | src/.../actions/generate-tracking-id/index.js (web, unauthenticated, read/generate only) |
| Database | tracking-ids collection + unique index on order_id, provisioned via the same customInstallationSteps pattern as erp-sync |
| Mesh source | mesh.json โ meshConfig.sources[], "handler": { "openapi": {...} } pointing at the deployed action |
| Schema stitch | mesh.json โ additionalTypeDefs (extends the order type with trackingId) + mesh/additional-resolvers.js (programmatic resolver, referenced from additionalResolvers) |
| CORS | mesh.json โ responseConfig.CORS.origin โ must include http://localhost:3000 and the deployed storefront origin before Step 2 points the storefront at this mesh |
Sourcing note: the exact GraphQL order type/field the storefront's My Account order view queries (commonly
CustomerOrder, keyed bynumber) isn't demonstrated end-to-end in either source repo for this workshop โ confirm it withcurlagainst your mesh (get the endpoint viaaio api-mesh:describe) or the Commerce endpoint directly, e.g.curl -s https://<your-mesh-endpoint>/graphql -H 'Content-Type: application/json' -d '{"query":"{ __type(name: \"CustomerOrder\") { fields { name } } }"}', before finalizing the resolver'stargetTypeName/sourceArgs.
aio api-mesh:update fails with "Unable to create a mesh" or the update never provisions.
Cause: A config error in mesh.json โ often an unreachable source (the action isn't deployed yet, or the OpenAPI document's URL is wrong) or a deprecated transform.
Fix: Confirm the runtime action is deployed and reachable with a plain curl first, validate the OpenAPI document is valid JSON/YAML, and avoid replaceField (deprecated โ use additionalResolvers instead).
generateCustomerToken itself returns a valid token (check the Network tab response body to confirm).
Cause: API Mesh doesn't forward the client's Authorization header to the Commerce source by default. The login mutation succeeds (it needs no auth), but the storefront's follow-up authenticated customer query is sent through the mesh with no Authorization header, so Commerce rejects it with a graphql-authorization error ("The current customer isn't authorized") โ which the storefront's login flow surfaces as a login failure.
Fix: Add "Authorization": "{context.headers.authorization}" to the Commerce source's operationHeaders in mesh.json, run aio api-mesh:update mesh.json, and retry โ confirm with check #6 above before touching the storefront again.
Cause: The order type/field name guessed for targetTypeName/sourceArgs doesn't match the real Commerce GraphQL schema, or the Tracking source's OpenAPI document doesn't actually describe the response shape the resolver expects.
Fix: Introspect both schemas independently ({ __type(name: "CustomerOrder") {...} } against Commerce; a direct call against the Tracking source) before wiring additionalResolvers.
trackingId: null for every order, even though tracking-ids has documents for them.
Cause: sourceArgs in additionalResolvers maps the wrong root field (e.g. {root.entity_id} when the order type's real identifier field is number), so the Tracking source never receives a matching order_id.
Fix: Match sourceArgs to the field actually available on root for that type โ verify with a query that returns both the identifier field and the stitched trackingId together.
trackingId comes back null alongside a GraphQL error like "No type was found for field node { kind: \"Field\", name: { kind: \"Name\", value: \"__typename\" } }" (path ending in trackingId).
Cause: A known API Mesh limitation with the declarative additionalResolvers shorthand when the target field is a scalar (trackingId: String) but the source field it delegates to returns an object (generateTrackingId โ { order_id, tracking_id }). Because the client's query has no sub-selection on a scalar field, Mesh has nothing to forward to the object-returning source field, and its automatic __typename handling for that delegation breaks.
Fix: Use a programmatic resolver instead (a mesh/additional-resolvers.js file referenced from additionalResolvers), and call the source field with an explicit selectionSet: '{ tracking_id }' so Mesh knows exactly which sub-field to fetch โ see the code example in this step's Agent Prompt.
Check it works
0/6Expand a check for verification steps, or mark confirmed if you already know it passes.
aio runtime action list
Shows generate-tracking-id deployed. Get its URL and call it directly:
aio runtime action get generate-tracking-id --url
curl -s "https://<your-action-url>?order_id=<id>"
Returns { order_id, tracking_id }.
aio api-mesh:update mesh.json
aio api-mesh:status
Reports success after adding the Tracking source.
Get the mesh's GraphQL endpoint:
aio api-mesh:describe # prints the mesh ID and its https://.../graphql endpoint URL
curl -s https://<your-mesh-endpoint>/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ __schema { types { name } } }"}'
Confirms the mesh schema includes your extension. Querying the order type
with the new field (same endpoint, {"query":"{ ... trackingId }"}) returns
a tracking ID matching what the action returned directly.
aio app db document find tracking-ids '{}'
Shows one document per order that has been queried at least once through
the mesh, each with a unique order_id.
curl -si https://<your-mesh-endpoint>/graphql \
-H 'Origin: http://localhost:3000' \
-H 'Access-Control-Request-Method: POST' \
-X OPTIONS
The response includes Access-Control-Allow-Origin: http://localhost:3000
(not the mesh's own domain, and not a mismatched origin) โ confirming
before Step 2 points the storefront at this mesh, rather than debugging a
browser CORS error later.
TOKEN=$(curl -s https://<your-mesh-endpoint>/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { generateCustomerToken(email: \"<test-customer-email>\", password: \"<test-customer-password>\") { token } }"}' \
| jq -r '.data.generateCustomerToken.token')
curl -s https://<your-mesh-endpoint>/graphql \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ customer { email } }"}'
The second call returns the customer's data, not a graphql-authorization
error โ confirming operationHeaders on the Commerce source forwards
Authorization through the mesh, before Step 2 wires up the storefront's
login flow.
Display the tracking ID in My Account
Surface the mesh-stitched trackingId field on the storefront's My Account
order history/details view, using the same drop-in slot extension pattern
from Extend Storefront.
Show the tracking ID from the mesh-stitched order type on the storefront's
My Account order history/order details page.
Find the order-related drop-in (likely @dropins/storefront-order or
equivalent โ inspect node_modules/@dropins/ for the exact package name,
since it isn't demonstrated in this project's source material) and its
container used on My Account's order history/detail view. Extend that
container with a slot (same slots API as the product grid disclaimer:
appendChild, prependChild, onChange, etc.) that renders the trackingId
field returned by the order query, once the mesh schema exposes it.
Render at the order-summary/container level (the top-level order data
listener), not inside a per-shipment slot (e.g. a shipping-status/delivery
timeline slot scoped to order.shipments). Shipment-scoped slots only run
once per shipment and are skipped entirely when order.shipments is empty
-- the normal case for a Pending/unshipped order -- which would silently
hide a valid trackingId on exactly the orders where a customer is most
likely to check it. Prepend/append the tracking ID text directly from the
order container's own data listener instead, e.g. in
blocks/commerce-shipping-status/commerce-shipping-status.js, so it renders
regardless of shipment count.
If the order drop-in's existing query doesn't already request trackingId,
update its GraphQL query/fragment to include the new field so it's
available to the container's onChange handler.
Render "Tracking ID: <value>" near the order status/summary, and render
nothing (not an empty label) when trackingId is null โ some orders may not
have shipped yet and won't have a tracking ID.
Verify locally against your deployed mesh endpoint before publishing.
| Piece | Location |
|---|---|
| Order drop-in query | wherever the order history/detail container fetches order data โ add trackingId to the query (confirm exact drop-in/container name โ see caveat) |
| Tracking ID render | blocks/commerce-shipping-status/commerce-shipping-status.js โ from the order container's own data listener, not a per-shipment slot (skipped when order.shipments is empty) |
| Backend | the storefront's config.json commerce-endpoint must point at the API Mesh from Step 1, not the raw Commerce GraphQL endpoint |
graphql-authorization error, or a similar "not authorized"/scope error, in the response body).
Cause: The mesh's Commerce source isn't forwarding a header core Commerce needs for this query โ most commonly Authorization (see Step 1's login Troubleshooting entry for the exact failure signature), but also possibly Store/Magento-Store-Code/the ACO ac-view-id equivalent for multi-store setups โ or CORS isn't configured for the storefront's origin on the mesh.
Fix: Confirm operationHeaders on the Commerce source forward Authorization ("{context.headers.authorization}") and any required store-scope headers, and that responseConfig.CORS.origin includes your storefront's exact origin (an explicit origin, not *, especially if credentials: true). As a fallback to isolate the issue, point commerce-core-endpoint directly at Commerce while keeping discovery through the mesh.
Access to fetch at 'https://<your-mesh-endpoint>/graphql...' from origin 'http://localhost:3000' has been blocked by CORS policy: ... The 'Access-Control-Allow-Origin' header has a value '<mesh-endpoint>' that is not equal to the supplied origin after pointing the storefront's config.json at the mesh endpoint.
Cause: mesh.json has no responseConfig.CORS block (or one that doesn't list your dev origin), so the mesh doesn't send a matching Access-Control-Allow-Origin header on the preflight response โ the browser blocks the request before it ever reaches the mesh.
Fix: Add a responseConfig.CORS.origin array to mesh.json that includes http://localhost:3000 (and your deployed storefront origin), run aio api-mesh:update mesh.json, and reload โ don't use * if credentials: true is also set, since browsers reject that combination.
trackingId correctly via curl.
Cause: The order drop-in's own GraphQL query/fragment doesn't request the new field, so the container never receives it even though the schema supports it.
Fix: Update the drop-in's order query to include trackingId, and confirm the slot's onChange handler actually re-renders when new order data arrives.
trackingId is confirmed present in the GraphQL response (via curl or DevTools) but still doesn't render on the order detail page.
Cause: The slot only renders inside the shipping-status container's per-shipment loop (e.g. a DeliveryTimeLine slot), which is skipped entirely when order.shipments is empty โ the normal case for a Pending/unshipped order ("shipments": []). The slot callback never runs, even though the GraphQL fragment/model-transformer plumbing for trackingId is correct.
Fix: Render at the container level instead of the shipment-scoped slot โ in blocks/commerce-shipping-status/commerce-shipping-status.js, prepend the "Tracking ID: โฆ" text directly from the order/data listener rather than a per-shipment slot, so it renders for pending/unshipped orders too (adjust CSS margin/README to match).
trackingId after deploying to the edge mesh.
Cause: Caching is enabled (responseConfig.cache: true) and the Tracking source doesn't return a public Cache-Control header, or a stale cached response is being served after the tracking ID was generated.
Fix: Check response headers for Cache-Status; if caching is on, purge with aio api-mesh:cache:purge -a after generating new tracking IDs, or don't cache this resolver's responses if tracking IDs must always be fresh.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
config.json's commerce-endpoint (and/or commerce-core-endpoint, if
My Account order queries go through core Commerce) points at the mesh
endpoint and includes the mesh's x-api-key header.
On the storefront, sign in as a customer with at least one past order, open My Account โ Order History โ an order's detail view โ the tracking ID renders next to the order status.
For an order with no tracking ID yet (no tracking-ids document), the
detail view renders cleanly with no "Tracking ID: null" or blank label.
aio app db document find tracking-ids '{"order_id": <id>}'
Confirm the tracking ID shown in My Account matches this document for the same order.
(Optional โ Stretch Goal) Fix Extend Storefront's two-sources-of-truth gap
Extend Storefront's Step 4 disclaimer reads its dollar amount from an
authored da.live field, deliberately separate from the real
order_approval_threshold business-config value from Extend Business
Logic โ a known limitation called out explicitly on that page, left
unresolved until you had a runtime-action-plus-mesh pattern to fix it with.
You now do. This step replaces the authored field with a live value, using
the exact same shape as Step 1 above: a small public action, exposed
through the mesh you already built.
Concept 1 โ Build and deploy the approval-threshold action
Same shape as Step 1's tracking action: get a real, callable HTTP endpoint
deployed first โ API Mesh's openapi source handler needs a live URL to
describe, not a plan for one.
Add a second public runtime action, get-approval-threshold, alongside
generate-tracking-id in this App Builder app.
It takes no input. Read the order_approval_threshold business-config value
the same way hold-order-for-approval (Extend Business Logic, Step 2) reads
it, and return it as JSON from a GET request:
{ "order_approval_threshold": <number> }
Build and deploy this action before moving on -- the next step needs a
real deployed endpoint to point API Mesh's OpenAPI source at.
Concept 2 โ Expose it as a root Query field on the mesh
Unlike trackingId, this value isn't tied to an order โ it's a single
global config value, so it's added as a plain root Query field instead
of being stitched onto an existing type.
Add a second source to the same mesh.json used for Tracking, named
"ApprovalThreshold", using the openapi handler pointing at a minimal
OpenAPI document for the get-approval-threshold action's GET response.
Add it as a plain root Query field:
extend type Query {
orderApprovalThreshold: Float
}
with an additionalResolvers entry resolving it from the ApprovalThreshold
source (no sourceArgs needed -- it's a no-argument lookup, not keyed off
another type's field).
Run aio api-mesh:update mesh.json and aio api-mesh:status until it reports
success.
Concept 3 โ Read the live value from the storefront, with a fallback chain
With the mesh resolving a live value, replace Extend Storefront's authored field with it, without ever letting the disclaimer disappear or throw if the mesh query fails.
Update blocks/product-list-page/product-list-page.js from Extend
Storefront Step 4: query orderApprovalThreshold from the mesh (the same
GraphQL client/helper this storefront already uses for other queries,
e.g. CS_FETCH_GRAPHQL in scripts/commerce.js) instead of reading the
authored "Approval Threshold" field. Keep the authored field and the
hardcoded 500 as a two-level fallback, in that order, if the mesh query
fails or returns null -- the disclaimer should never disappear or throw
just because this new query had a problem.
Verify locally with npm start.
| Piece | Location |
|---|---|
| Threshold action | src/.../actions/get-approval-threshold/index.js (web, unauthenticated, read-only) |
| Mesh source | mesh.json โ new ApprovalThreshold source (openapi handler) |
| Schema addition | mesh.json โ additionalTypeDefs (extend type Query { orderApprovalThreshold: Float }) + additionalResolvers |
| Storefront fetch | blocks/product-list-page/product-list-page.js, replacing the Extend Storefront Step 4 authored-field read |
| Fallback chain | mesh value โ authored field โ hardcoded 500 |
orderApprovalThreshold always returns the value from when the mesh was last deployed, even after changing the business config.
Cause: Same mesh caching behavior as the Tracking source in Step 1 โ a cached response is being served instead of a fresh call to the action.
Fix: Purge the mesh cache (aio api-mesh:cache:purge -a) after changing the business config, or disable caching on this resolver specifically since the whole point is that it must reflect the current config.
extend type Query addition.
Cause: additionalTypeDefs conflicts with a Query field that already exists on the Commerce schema, or the OpenAPI document for ApprovalThreshold doesn't match the action's actual response shape.
Fix: Pick a field name that doesn't collide with the core schema (introspect first with { __type(name: "Query") { fields { name } } }), and validate the OpenAPI document against a direct curl of the action.
$500 (the hardcoded fallback) even though both the mesh and the authored field have real values.
Cause: The fallback chain short-circuited too early โ the mesh fetch likely threw before its result was checked, so the code fell straight through to the last fallback.
Fix: Wrap the mesh fetch in its own try/catch that falls through to the authored field on failure, keeping the hardcoded 500 as the last resort only.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
Get the mesh's GraphQL endpoint:
aio api-mesh:describe # prints the mesh ID and its https://.../graphql endpoint URL
curl -s https://<your-mesh-endpoint>/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ orderApprovalThreshold }"}'
Returns the same number currently set for Order approval threshold (USD) in the app's business configuration.
Change Order approval threshold (USD) in Commerce Admin's business
configuration to a new value. Without touching da.live or redeploying the
storefront, reload /back-to-school โ the disclaimer's dollar amount
reflects the new value. This is the concrete fix for the gap called out
in Extend Storefront: one source of truth, not two.
Temporarily break the mesh query (e.g. point it at a wrong source name)
and reload /back-to-school โ the disclaimer still renders, using the
authored field or the 500 default, not a blank space or a thrown error.
๐ก Operate Your Storefront
DA.live, Publish, Content, Personalization, Experimentation, Scheduling
Back to School launches tomorrow. Your storefront and DA.live access already
exist, and AEM Sidekick is already installed โ this page assumes both,
same as the rest of this workbook. This lab is deliberately one artifact,
built up step by step: the storefront's own home page (index).
Before touching it by hand, you'll get oriented in Adobe's newest
authoring surface, Experience Workspace; every step after that edits the
same document, so by the end you have one coherent campaign โ
personalized, tested, and scheduled to launch all at once, with
merchandising layered on afterward: Explore โ Author โ Personalize โ
Experiment โ Schedule โ Recommend.
Recommended campaign challenge: tomorrow is Back to School. The storefront is live, but today the home page is generic. In under 90 minutes, get oriented in Experience Workspace, then turn the home page into a personalized, tested home page โ scheduled to launch, all at once, at the exact moment the sale starts โ then layer on product recommendations to round out the campaign.
Explore Experience Workspace
Start with a look at the agentic direction of merchant authoring, before building today's campaign by hand. Experience Workspace is an intelligent authoring surface where humans and AI agents collaborate to build, edit, and optimize digital experiences. It's currently Early Access and disabled by default. This step is a guided tour, run in four small moves: set it up, prove the two editing modes agree, prove conversational editing actually edits, then watch the AI Assistant pull live storefront data through MCP โ Step 2 then switches to DA.live's classic editor to build the real campaign.
Merchant outcome: participants leave this step with a concrete picture of the next merchant workflow โ moving from manual point-and-click operations toward AI-assisted, data-aware authoring โ before spending the rest of the lab building the actual campaign by hand, with human review and publication control retained throughout.
Set up Experience Workspace
- Enable it the easy way: open the
guided setup tool
and follow its walkthrough โ it sets the
ew.enabledflag and the SidekickeditUrlPatternso Edit actions route straight into the canvas. - Open the storefront's home page โ still generic, before this lab's edits โ through Sidekick's Edit action. If it opens the classic document editor instead of the canvas, toggle New Authoring in the document editor's header (a per-author, per-session setting) to switch into Experience Workspace.
Show the two editing modes agree
- With the canvas open, make one small edit directly in the Layout view (the rendered page) โ e.g. nudge the Hero subcopy's wording.
- Switch to the Content view (the page's document structure) and confirm the same edit is already there โ it's one document, viewed two ways, not two separate copies to keep in sync.
- Make a second small edit from the Content view instead, switch back to Layout, and confirm that one shows up too.
Show conversational creation actually edits
- Open the AI Assistant chat panel alongside the canvas.
- In natural language, ask it to make one concrete change โ e.g. "Update the Hero headline to something more urgent for a Back to School sale launch."
- Confirm the canvas updates with the assistant's edit, without any manual clicking in the canvas itself.
- Discuss the human-review boundary: this is a live illustration, not the real content change โ don't publish it. Step 2 authors the actual teaser copy from scratch with DA.live's classic document editor, so there's nothing here to undo, just leave it unpublished.
Show Skills, MCP, and the Commerce Storefront MCP
- Explain the mechanic before the demo: the AI Assistant's Skills are
capabilities it invokes automatically when relevant, or that you trigger
yourself with
/in chat; MCP is how it reaches Adobe or third-party tools and data, configured per-workspace under the assistant's MCP settings. - Live demo: with the Commerce Storefront MCP connector already configured in this workspace, ask the AI Assistant in chat: "What are the top 5 products on this storefront?"
- The assistant calls the Commerce Storefront MCP skill to query the live storefront and answers with real catalog data in the chat โ not a generic or made-up list.
- Tie it forward to the rest of the lab: this is the same storefront data the personalization blocks (Step 3) โ and Step 6's recommendations rail โ will be built on later, reachable here conversationally instead of only through Admin screens and blocks.
Cause: The site isn't enabled for Experience Workspace, or the required setup is incomplete.
Fix: Re-run the guided setup tool, or run the demo from a prepared pilot site. Keep the core lab independent of this step.
Cause: The ew-setup tool itself is unreachable, or its access is restricted for this org.
Fix: Fall back to the manual setup documented on the Experience Workspace docs page (site/org config flags plus the Sidekick editUrlPattern config), or run the demo from a prepared pilot site.
Cause: The ew.enabled flag or the Sidekick editUrlPattern config wasn't set (guided setup incomplete or skipped), or ew.enabled is set but this particular author hasn't switched over yet.
Fix: In the document editor's header, toggle New Authoring on โ a per-author setting that works even without the editUrlPattern change. If that toggle isn't there at all, re-run the guided setup tool to set the ew.enabled flag.
Cause: Chat may be disabled via ew.disableChat, or the current Early Access capability may differ.
Fix: Don't block the lab on the AI portion; continue with the Layout/Content view demonstration and document the limitation.
Cause: The MCP connector isn't configured for this workspace, or the session lost its connection.
Fix: Confirm the connector under the workspace's MCP settings before the lab starts; if it fails live, explain the capability and move on rather than troubleshooting MCP configuration in front of the group. > Facilitator note: this is intentionally a short tour, not a setup > exercise โ the real campaign build starts in Step 2. Rehearse the > Commerce Storefront MCP query beforehand so it's not the first time you > see its answer; don't make an Early Access dependency part of the lab's > completion criteria, and don't let this step run long โ it's an > appetizer, not the main build.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
An edit made in either view of the canvas is immediately visible in the other, with no separate save/sync step.
The AI Assistant's response to a natural-language instruction is reflected in the canvas itself, not just described in the chat.
Asking for the top 5 products returns actual storefront catalog data in the chat, not a hallucinated or generic-sounding list.
The relationship between AI assistance and human approval โ nothing from this demo is published, and the real campaign is authored by hand starting in Step 2 โ is explicit.
Author & publish a Back to School teaser
You already learned the document โ preview โ publish mechanic in Extend
Storefront's Step 1, and just saw Experience Workspace's canvas in Step 1
of this lab โ this step applies DA.live's classic document editor to a
real campaign edit instead. Everything else in this lab builds on the same
page you touch here โ the storefront's home page (index).
Merchant outcome: shoppers visiting the storefront today see a short "Back to School is coming" teaser on the home page โ small, safe, and already live, while the bigger campaign gets built underneath it for the rest of this lab.
Prerequisites: AEM Sidekick installed, and DA.live access to the storefront's
indexdocument โ both assumed done before this lab.
- Open the storefront's
indexdocument in DA.live (its home page). - Locate the Hero block.
- Update its headline/copy to a short teaser, e.g.
Back to School is coming./Get ready to save. The full deal drops soon. - Add a CTA below the subcopy: on its own line, with nothing else in that
paragraph, add a bold link โ e.g.
**[Shop Back to School Deals](/back-to-school)**, pointing at the Office & Paper category page you built in Extend Storefront's Steps 1โ3 โ this teaser drives real traffic to that page, not a placeholder link. A bold link alone in its own paragraph is the same site-wide convention used everywhere else on this storefront to turn a plain link into a styled primary button โ no CSS or code change needed, it's already built into the theme. - Use DA.live's own Preview action, confirm the change renders โ headline, subcopy, and the CTA as a button โ and the full preview URL matches, then click Publish.
- Open the live URL and confirm the teaser is visible to real shoppers now.
Or, using Experience Workspace's AI Assistant: with the canvas open on
index(from Step 1), ask it in chat to make the same edit โ e.g. "Update the Hero headline to 'Back to School is coming.' and the subcopy to 'Get ready to save. The full deal drops soon.', then add a bold CTA link below it reading 'Shop Back to School Deals' pointing to /back-to-school." Confirm the change in either view, then Preview and Publish exactly as above.
Cause: Preview reflects the last saved version of the document, and the edit hadn't finished saving yet.
Fix: Let the document finish saving (or save explicitly), then re-open Preview.
Cause: The link wasn't alone in its own paragraph (other text shared the line), or it wasn't wrapped in bold.
Fix: Put the link on its own line with nothing else in that paragraph, wrap it in **...**, and re-preview.
Cause: Edge propagation can take a few seconds, or Publish was clicked before the document finished saving.
Fix: Wait a few seconds and reload the live URL; if it still doesn't match, confirm the edit is saved in the document, then Publish again. > Facilitator note: keep this step tight โ it's a quick recap/ > application of the Preview โ Publish mechanic (already taught in Extend > Storefront), not a place to polish hero copy. Every step after this one > stays in Preview only, until Step 5 schedules the real launch โ call > that pattern out now so it isn't a surprise later.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
DA.live's Preview shows the new Hero headline/subcopy, not the previous placeholder content.
The Shop Back to School Deals link shows up styled as a button โ confirming
it was authored alone in its own bold paragraph, not just as an inline
link.
Both DA.live's preview URL and the live URL show the same teaser copy and CTA button.
Every other block on the home page still renders as it did before โ only the Hero content changed.
Personalize Content
Show how the same home page can present different content depending on whether the shopper is logged in. Personalization blocks can target Adobe Commerce Customer Groups โ this storefront's default groups give a clean example: General (logged-in) vs. NOT LOGGED IN (guest). The two banners are backed by small content fragments you'll create yourself in this step โ kept deliberately simple, one line of text each.
Merchant outcome: a logged-in shopper sees an "early access, unlocked" banner, while a guest sees a generic "sign in for early access" banner in the same spot on the same page.
Prerequisites: this storefront's actual Customer Group IDs confirmed in Commerce Admin's Customers > Customer Groups (commonly General =
1and NOT LOGGED IN =0, but these can be renumbered). Nothing else needs to exist yet โ this step creates its own fragments and its own test customer account.
Create a test customer account (Storefront)
Previewing the "logged-in" case needs an actual logged-in session โ there's no shortcut around creating a real customer account first.
- On the storefront, use Create an Account (its sign-up flow) to
register a new test customer, e.g.
backtoschool-tester@example.com. - Complete any confirmation the storefront requires, then log in with that account.
- In Commerce Admin's Customers > All Customers, find the new customer and confirm it landed in the General group โ the default for a self-registered account, and the group the early-access banner below targets.
- Keep this logged-in session in one browser (or profile) so it's ready for the logged-in preview below, and use a separate incognito/private window โ with no session at all โ for the guest preview.
Create the two fragments (DA.live)
- In DA.live, navigate to (or create) the
fragments/promosfolder. - Create a new document named
back-to-school-early-access. Its entire content is one line of plain text:๐ Early access unlocked โ shop Back to School deals now. - Create a second new document named
back-to-school-signin. Its entire content is one line of plain text:Sign in to unlock early access to Back to School deals. - That's it โ no blocks, no images. Preview each one briefly (using DA.live's Preview) to confirm it renders as plain text.
Add the targeted blocks (DA.live)
Open the
indexdocument and insert atargeted-blockdirectly below the Hero. Filled in, it'll look like this:targeted-block Type home-top Customer Groups 1 Fragment /fragments/promos/back-to-school-early-access Add a second
targeted-blockwith the sameType, no Customer Groups condition, and the sign-in fragment โ this is the fallback:targeted-block Type home-top Fragment /fragments/promos/back-to-school-signin Preview as the logged-in test customer account you created above and confirm the early-access banner appears.
Preview as a guest, in the separate incognito/private window with no session, and confirm the sign-in banner appears instead.
Leave the document in Preview only.
Or, using Experience Workspace's AI Assistant: with the canvas open on
index, ask it to insert both blocks directly โ e.g. "Add a targeted-block below the Hero: Type home-top, Customer Groups 1, Fragment /fragments/promos/back-to-school-early-access. Then add a second targeted-block with the same Type, Fragment /fragments/promos/back-to-school-signin, and no Customer Groups condition." Confirm the resulting block tables match the ones above, then preview both sessions exactly as above.
Cause: This storefront's default customer group for self-registration was reconfigured away from General, or the account was created directly in Admin under a different group.
Fix: In Commerce Admin's Customers > All Customers, edit the account's Account Information and set its Group to General directly, or check Stores > Customer Groups for a reassigned default.
targeted-block's Fragment field can't find the document.
Cause: The fragment path doesn't match where it was created, or it was never previewed after creation.
Fix: Confirm the fragment lives at exactly /fragments/promos/back-to-school-early-access (or -signin), and Preview it once on its own (using DA.live's Preview) before referencing it from the targeted block.
Cause: The Customer Groups value is wrong or missing, or the test shopper session isn't actually logged out.
Fix: Verify Customer Groups is set to the correct General group ID, and test the guest case in an incognito/private window.
Cause: The group ID doesn't match this storefront's actual General group โ default IDs can be customized.
Fix: Confirm the real group ID in Commerce Admin's Customers > Customer Groups, and update the block's value to match.
Cause: The two targeted-blocks don't share the exact same Type value.
Fix: Make sure both blocks use identical Type: home-top.
> Facilitator note: the cleanest demo is comparing a signed-in and a
> signed-out preview side by side. Keep the lesson to contextual content,
> not an exhaustive tour of every targeting option.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
Previewing as a shopper in Customer Group 1 displays the early-access
banner.
Previewing as a guest (no session) displays the sign-in banner instead.
Only one home-top banner is ever shown โ never both, never neither.
The live URL does not yet show either banner.
Run an experiment
Turn "does the CTA matter" into two separate measurable tests instead of
one guess. First install the aem-experimentation Sidekick plugin โ
a multi-file code change, so hand it to your AI coding agent rather than
editing files by hand โ then use it to create an A/B/n experiment on the
home page with two challenger variants, each isolating a different
single change from the control: does better wording move the needle,
or does just making the CTA a button at all?
Merchant outcome: a control and two challenger home pages โ one testing a different CTA wording, the other testing whether the CTA is a standalone button at all โ configured and ready to run, but not yet visible to real shoppers.
Prerequisites: your AI coding agent connected to this storefront project (the same one set up in Tooling/Provisioning), and Admin API access to your own site's persisted Sidekick config.
Install the AEM Experimentation plugin into this storefront project,
following https://github.com/adobe/aem-experimentation/blob/main/README.md
exactly (main branch โ the README's own "v2" reference is stale, the repo
has no v2 branch/tag):
1. From the repo root, run:
git subtree add --squash --prefix plugins/experimentation \
https://github.com/adobe/aem-experimentation.git main
2. Create scripts/experiment-loader.js using the README's template, exposing
runExperimentation(document, config) and runExperimentationLazy(document,
config).
3. In scripts/scripts.js: import both functions from './experiment-loader.js',
add an experimentationConfig object (set prodHost to this project's
production host; leave audiences empty for now), call
`await runExperimentation(doc, experimentationConfig)` inside loadEager(),
and `await runExperimentationLazy(doc, experimentationConfig)` inside
loadLazy().
4. In head.html, add:
<link rel="modulepreload" href="/scripts/experiment-loader.js" />
5. In tools/sidekick/config.json, add the plugin entry from the README (id
"aem-experimentation", titleI18n.en "Experimentation", environments
["preview"], event "aem-experimentation-sidekick") to the plugins array โ
create the array if it doesn't exist, and don't remove any existing
plugin entries.
Commit and push when done so the site rebuilds with the plugin included.
Configure Sidekick for your site
- Once deployed, configure Sidekick for your own site: the repo's
tools/sidekick/config.jsonentry alone isn't enough on a project using the unified site config โ merge the same plugin entry into your site's persisted config with aPOSTtohttps://admin.hlx.page/config/<org>/sites/<site>.json. Merge into the existingsidekick.pluginsarray โ don't overwrite it, or you'll wipe out any other Sidekick plugins already configured for your site. This part needs your own authenticated admin session, so do it yourself rather than asking the coding agent. - Reload Sidekick while previewing the home page and confirm an Experimentation button now appears.
Create the experiment
- Per the
aem-experimentationconvention, variant pages live in a dedicated folder named after the lowercase experiment ID:/experiments/back-to-school-hero-cta/. In DA.live's Browse view, create that folder first. - Select the checkbox next to the
indexdocument, choose Copy, then Paste it into the new folder โ twice, once per challenger โ renaming the two copieschallenger-1andchallenger-2(/experiments/back-to-school-hero-cta/challenger-1and.../challenger-2). - Open
challenger-1and change only the CTA's wording, e.g. fromShop Back to School DealstoSave 40% Today Only. Leave it wrapped in its own bold paragraph exactly as the control has it, so it still renders as a button โ this challenger isolates wording. - Open
challenger-2and leave the wording alone. Instead, remove the CTA's standalone paragraph and fold the sameShop Back to School Dealstext into the end of the subcopy paragraph as an ordinary inline link (not bold, not alone on its own line) โ e.g.Get ready to save. The full deal drops soon. Shop Back to School Deals.Because the link no longer sits alone in its own paragraph, the site's button convention doesn't kick in and it renders as a plain text link โ this challenger isolates prominence (button vs. plain link), with identical wording to the control. - Preview both challengers: confirm
challenger-1shows a button with different text, andchallenger-2shows the same text as a plain inline link, not a button. - While previewing the control (
index), use Sidekick's Experimentation button to create a new experiment: Name (back-to-school-hero-cta), Type (A/B test), Goal (e.g.Overall conversion); Control auto-populates with the current page URL; set variant-1 tochallenger-1's URL, add a second variant row and set variant-2 tochallenger-2's URL; set Start/End dates to the Back to School launch window. - Save the experiment โ do not click Publish in the Experimentation panel yet, and do not publish any of the three pages.
The panel does its work by writing an Experiment entry into the
control page's own metadata block โ for reference, that's what actually
ends up on index once you save. With two challengers, Experiment
Variants holds both URLs, one per line, and traffic splits evenly three
ways (control + both challengers) by default:
| metadata | |
|---|---|
| Experiment | back-to-school-hero-cta |
| Experiment Variants | https://main--<repo>--<org>.aem.page/experiments/back-to-school-hero-cta/challenger-1 https://main--<repo>--<org>.aem.page/experiments/back-to-school-hero-cta/challenger-2 |
| Experiment Status | Draft |
| Experiment Start Date | 2026-08-14 |
| Experiment End Date | 2026-08-28 |
Cause: The agent's changes weren't committed/pushed yet, the deploy hasn't finished propagating, or the site-level config POST was never sent.
Fix: Confirm the agent committed and pushed its changes, give the deploy a minute to build, then confirm the POST to admin.hlx.page succeeded before reloading Sidekick.
Cause: The POST to the site's config replaced the whole sidekick.plugins array instead of merging into it.
Fix: GET the current config first, merge the aem-experimentation entry into the existing array, then POST the merged result back.
Cause: The wrong document was edited, or the edit wasn't saved before previewing.
Fix: Reopen the specific challenger (challenger-1 or challenger-2), confirm its change was made, and re-preview.
challenger-2's CTA still renders as a button instead of a plain link.
Cause: The link ended up alone in its own paragraph again (e.g. pasted on its own line, or still wrapped in bold) โ that's exactly the pattern that triggers the site's button convention.
Fix: Confirm the link shares a paragraph with other subcopy text, and that it isn't wrapped in **...**, then re-preview.
Cause: The second variant row was never added in the Experimentation panel, or the panel was saved before setting variant-2.
Fix: Reopen the Experimentation panel on the control, add the missing variant row, set it to challenger-2's URL, and save again.
Cause: Publish was clicked in the Experimentation panel, or one of the three pages was published early.
Fix: Better to catch this now than on launch day โ pause the experiment and unpublish the early page, then hold everything in Preview until Step 5's scheduled snapshot.
> Facilitator note: this is the one step in the lab that touches the
> storefront's code repo, not just content. The Agent Prompt covers the
> repo-level wiring end to end; only the site-level Sidekick config POST
> needs a human with admin access, so don't let participants skip it
> waiting for the agent to do it.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
Previewing the home page shows an Experimentation button in Sidekick โ confirming both the repo-level plugin install and the site-level config POST succeeded.
challenger-1 differs from the control only in CTA wording (still a
button). challenger-2 differs from the control only in CTA prominence
(same wording, but a plain inline link instead of a button). Neither
challenger changes both at once.
The experiment's control, variant-1, and variant-2 URLs match index,
challenger-1, and challenger-2 respectively, both living under
/experiments/back-to-school-hero-cta/.
The Experimentation panel shows the experiment as saved/draft, and none of the control or the two challenger pages have been published.
Schedule the launch
Use a Snapshot to launch the whole campaign โ the updated home page and its running experiment โ at once, for the Back to School launch, rather than manually publishing pieces one at a time and hoping the timing lines up. A snapshot bundles all three pages into one unit, with its own scheduled publish date/time, so they go live together as a single action.
If you haven't used snapshots in DA.live before: a snapshot is a locked,
point-in-time bundle of the pages you add to it โ not a live view of
whatever those documents contain later. Finish all your edits on index,
challenger-1, and challenger-2 first, then build the snapshot from
them; if you edit any of the three pages again afterward, update the
snapshot's URLs (or create a fresh one) so it picks up your latest
changes.
Merchant outcome: the entire campaign โ personalization and the live A/B/n test โ goes live automatically at the exact moment the Back to School sale starts. Nothing published early, nothing forgotten at the last minute.
Prerequisites: the control (
index) and both challengers (challenger-1,challenger-2) finished and ready in Preview from Steps 2โ4.
Register the site for scheduling
- Open the Scheduler App and enter your
org/site. - If this site hasn't been registered for scheduling yet, the app prompts you to register it โ follow the prompt.
- One-time per site; skip this if it's already registered.
Create the snapshot
- Open the Snapshots app at
da.live/apps/snapshots#/<org>/<site>. - Click Add new.
- Enter a snapshot name, e.g.
back-to-school-launch. - In URLS, paste the URLs for
index,challenger-1, andchallenger-2. - Fill in Title and Description for reference (leave Password blank unless you specifically want to restrict access to the snapshot).
Schedule and publish it
- Under Schedule Publish, set the date/time to the Back to School launch moment (or your campaign's actual start time).
- Click Save, under Snapshot, to save your changes.
- Skip Request Review โ for this lab, click Approve & Publish directly under Review to confirm the snapshot yourself; the Schedule Publish date/time you set is when it actually goes live, not the moment you click this button.
- Record the launch time and owner in the campaign notes.
Cause: This site hasn't been registered for scheduled publishing yet.
Fix: Follow the message's link to the Scheduler App (da.live/apps/scheduler) and register the site โ self-service, no facilitator or API token needed โ then retry.
Cause: One or more URLs are missing from the snapshot, or the snapshot wasn't approved.
Fix: Re-open the snapshot, confirm index, challenger-1, and challenger-2 are all listed in URLS, and confirm it shows as approved.
Cause: The snapshot was saved but never approved (Approve & Publish was skipped), or the scheduled date/time was set incorrectly.
Fix: Re-open the snapshot to confirm its approval status and scheduled date/time.
Cause: The snapshot bundled the pages as they were before the later edit โ editing a document afterward doesn't automatically update a snapshot that already references it.
Fix: Re-open the snapshot and re-save it (or create a fresh one) so it picks up the latest content. > Facilitator note: this is the payoff for holding everything in > Preview since Step 3 โ make the "everything launches together, on > schedule" moment explicit. This also replaces the scheduling approach > currently described in Operate Your Storefront's Step 1. Skipping > Request Review and self-approving is fine for this lab; in a real > launch, a second set of eyes on Approve & Publish is worth keeping.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
index, challenger-1, and challenger-2 are all listed in the
snapshot's URLs โ not just the control.
The snapshot's Schedule Publish date/time matches when the campaign is actually meant to go live.
Approve & Publish was clicked โ a saved-but-unapproved snapshot won't publish on schedule.
Add Product Recommendations
This step is a standalone merchandising add-on, not part of the scheduled
campaign. It comes after Step 5's launch deliberately: the index page
already went live on schedule, so this step layers a recommendation rail
on top, without threading it through the snapshot workflow above.
Make the home page smarter by adding a recommendation rail โ creating the recommendation unit yourself in Adobe Commerce Admin, then wiring it into the page. Product Recommendations can use shopper behavior and product context to surface relevant products. The home page isn't a PDP, so this is an anchor-based unit โ it needs an explicit seed SKU. That's where the Commerce Picker comes in: pick the seed product visually instead of typing a raw SKU by hand.
Merchant outcome: a "Shop our Back to School picks" rail appears on the home page, seeded from one concrete deal product and pulling in complementary items โ turning a static homepage into a data-driven discovery surface.
Prerequisites: at least one real product in your catalog picked as the Back to School deal/seed product. No recommendation unit needs to exist yet โ this step creates one, and the next installs the Commerce Picker if it isn't already in this document's Library.
Install Commerce Picker
One-time, per-site setup โ skip this if Commerce Picker already appears
under this document's Library menu. It's registered in the site's
config document (a separate sheet-based document from any storefront
page), not a page edit, so it's configured once for the whole site
rather than per document.
Open your storefront's
configdocument: from any document, click the gear icon, or go directly tohttps://da.live/config#/<your-org>/<your-site>/. This is a multi-sheet document โ the tabs across the top switch between its various config sheets (e.g.data,library, and others).Switch to the library sheet and add a new row with these five columns (
title/path/format/experience/iconโformatstays blank):title path format experience icon Commerce Picker https://main--aem-commerce-picker-dist--oneadobe.aem.page/dist/dist/document-authoring.html?configs-path=/config.json fullsize-dialog https://main--aem-boilerplate-commerce--hlxsites.aem.page/icons/cart.svg Click Save, then reopen the
indexdocument โ Library > Commerce Picker should now be available.If your storefront's Commerce config lives at a path other than
/config.json, adjust theconfigs-pathquery parameter in the Path value accordingly.
Create the recommendation unit (Admin)
- Go to Marketing > Promotions > Product Recommendations.
- Select the Store View this recommendation should run in, then click Create Recommendation.
- Under Name your Recommendation, give it an internal name, e.g.
Back to School Home Picks. - Under Select page type, choose Home Page.
- Under Select Recommendation type, choose one anchored to a specific product โ e.g. Bought this, bought that โ and check its readiness indicator before continuing. (On this Edge Delivery storefront, the Select placement/Choose position fields further down the form aren't load-bearing โ the block's actual position on the page is set by where you place it in the document, in the next section.)
- Under Storefront display label, enter the shopper-facing text:
Shop our Back to School picks. - Under Choose number of products, leave the default (5) or adjust to fit your layout.
- Click Activate (or Save as Draft if you'd rather finish wiring it up in DA.live first, and activate it later).
- Select the new unit from the Recommendations grid to open its details page, then copy its Recommendation ID.
Add the recommendation block (DA.live)
Open the same
indexdocument from Step 2 and add aproduct-recommendationsblock below the Hero. Like any Commerce block, it's a two-column table โ property names on the left, values on the right. Filled in, it'll look like this (with your own real ID/SKU in place of these example values):product-recommendations recId cf042e53-7efb-4a7e-b1bd-4f87d5c6ca84 currentSku ADB123 Fill in the
recIdrow with the Recommendation ID you copied.Add the required seed SKU: place your cursor in the
currentSkurow's value cell, open Library > Commerce Picker, search for and select the Back to School deal product, and confirm it inserts the product's SKU. (AcurrentPricerow is also supported, but only needed if your unit uses a dynamic/relative price filter โ leave it out here.)Preview it (using DA.live's Preview) to confirm the block renders with real product results.
Publish when ready. Since the main campaign already launched in Step 5, this add-on doesn't need to wait for a snapshot โ publish
indexdirectly, or fold it into your next scheduled snapshot if you'd rather batch it with a future change.
Cause: It hasn't been added to this site's DA.live config yet โ it's a one-time, per-site setup, not automatic.
Fix: Add the Library row under Install Commerce Picker above, then reopen the document.
Cause: That type needs more shopper history/data than this environment has collected yet โ common on a fresh catalog.
Fix: Pick a type with a "ready" indicator instead; a product-anchored type like "Bought this, bought that" or "Viewed this, viewed that" is usually available even with a small dataset.
Cause: The anchor-based unit is missing its seed SKU, or the unit itself has no eligible results.
Fix: Confirm the seed SKU was set via Commerce Picker, and check the unit's configuration and catalog eligibility.
Cause: The search term doesn't match the product's indexed name, or the picker is scoped to a different catalog/view than the storefront uses.
Fix: Search by SKU instead of name, and confirm the picker's catalog view matches the storefront's configured one. > Facilitator note: this is the first time this lab uses the Commerce > Picker โ pause to show what it actually returns (real, live catalog > data) versus hand-typing a SKU that might be stale or wrong.
Check it works
0/4Expand a check for verification steps, or mark confirmed if you already know it passes.
The recommendation block renders with no configuration error in Instant Preview.
The displayed products are relevant to the chosen unit and the seed product you selected.
The seed-product cell was populated by selecting the product in Commerce Picker โ confirm it resolved to the real SKU, not a hand-typed value.
Publishing index with the new block added the recommendations rail
without changing the Hero, personalization banners, or experiment already
live from the earlier steps.
๐งช Build / Dev Experience
CI/CD Recommendations
The outline poses this page as an open question โ "CI/CD?" โ rather than a fixed procedure, so treat everything below as a recommendation this workbook is making, built from the real local build/test commands in both source repos, not a pattern ported wholesale from either.
Sourcing gap, called out explicitly: neither
commerce-practice-buildernorpartner-lab-app-managementhas a.github/workflowsdirectory today (confirmed withfind . -type d -name workflowsacross both repos โ no results). Everything in this page describes commands that already exist and run locally in one of those repos or in this workbook repo; the YAML wrapping them into CI is this workbook's own synthesis, not a copy of an existing workflow.
CI for this workbook repo
This repo (commerce-app-worklab) is the smallest surface: a Node build
script and one devDependency (marked). Its package.json already defines
the two commands a workflow needs โ npm run build (โ
node tools/build-workbook.mjs, turning content/*.md into
dist/workbook.html) โ so CI here is just "does the workbook still build."
Add a GitHub Actions workflow at .github/workflows/build-workbook.yml for
this repo (commerce-app-worklab).
Trigger on push and pull_request against the main branch, for changes under
content/, tools/, or package.json.
Steps:
1. actions/checkout@v4
2. actions/setup-node@v4 with node-version 22 and the npm cache enabled
3. npm ci
4. npm run build
5. Upload dist/workbook.html as a build artifact (actions/upload-artifact@v4)
so a reviewer can download and open the rendered page from the PR without
building it locally.
Do not add a deploy step โ this repo has no hosting target, dist/ is
gitignored, and AGENTS.md says not to hand-edit or commit dist/. Build and
commit the workflow file when ready.
| Piece | Location |
|---|---|
| Workflow | .github/workflows/build-workbook.yml |
| Trigger paths | content/**, tools/**, package.json |
| Verified command | npm ci && npm run build (the exact commands in this repo's package.json) |
npm ci fails in CI but npm install works locally.
Cause: package-lock.json is missing or out of sync with package.json โ npm ci requires an exact lockfile match, unlike npm install.
Fix: Run npm install locally to regenerate the lockfile, commit it, and re-push.
marked.
Cause: marked is a devDependency; an npm ci --omit=dev or a production-only install step would skip it.
Fix: Use plain npm ci (no --omit=dev) since the build script itself is dev tooling, not a runtime dependency of the generated HTML.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
Push a branch with a trivial content/ edit and open a PR โ the
workflow run appears in the PR's checks.
The run's npm run build step succeeds and produces dist/workbook.html.
The uploaded artifact is downloadable from the workflow run summary and opens correctly in a browser.
CI/CD for the App Builder app (Steps 03โ05)
The App Builder app you built across Extend Business Logic, Extend
Storefront, and API Orchestration follows the same shape as
partner-lab-app-management: an app.commerce.config.ts, aio app build,
and aio app deploy to a Developer Console Stage workspace. Neither
source repo runs that deploy from CI, but commerce-practice-builder/app
does show a real, repo-local pattern for the two other pieces a CI/CD
pipeline needs: unit tests via plain node --test, and a drift check
(tools/sync-labs.mjs --check) that reports what's stale without writing
anything โ the shape to copy for keeping app.commerce.config.ts honest
against what the Agent Prompts in this workbook actually asked for.
Add a GitHub Actions workflow at .github/workflows/app-ci.yml for the
App Builder app built in this workbook (Extend Business Logic / Extend
Storefront / API Orchestration).
On pull_request, run three jobs:
1. build โ actions/checkout, actions/setup-node@v4 (node 22), npm ci,
then `aio app build`. Fail the job if the build fails.
2. test โ run `node --test` over the action handler test files this
workbook's earlier Agent Prompts asked for (the cart-quantity
calculation, the same-SKU double-count guard, the webhook field
mapping and exception shape from Extend Business Logic Step 1; the
order-hold field validation from Step 2). Model the npm script on
commerce-practice-builder/app's own "test": "node --test
\"test/**/*.test.js\"" pattern.
3. config-drift โ a small Node script that reads app.commerce.config.ts's
businessConfig.schema keys (max_cart_units, order_approval_threshold,
erp_sync_api_url) and fails with a clear message if any of those keys
referenced in content/03-extend-business-logic.md's Agent Prompts are
missing from the actual config file. Model this check's --check-only,
write-nothing behavior on tools/sync-labs.mjs --check from
commerce-practice-builder, adapted to this repo's config shape rather
than copied verbatim (that tool checks lab numbering, not App Builder
config).
On push to main only, add a fourth job, deploy, gated with a GitHub
Environment named "stage" requiring manual approval, running
`aio app deploy`. Do not add a deploy job that runs unattended on every
merge โ the Developer Console workspace this targets is a shared Stage
workspace, not a per-branch environment.
Build and commit the workflow file when ready.
| Piece | Location |
|---|---|
| Workflow | .github/workflows/app-ci.yml |
| Build check | aio app build (fails the job on config/manifest errors โ the same check partner-lab-app-management/workbook/02-initialize-project.md calls a prerequisite for Commerce Admin's Associate App step) |
| Test check | node --test โ real command from commerce-practice-builder/app/package.json's "test" script |
| Drift check | Custom script modeled on commerce-practice-builder/tools/sync-labs.mjs --check (report-only, no writes) |
| Deploy gate | GitHub Environment stage with required reviewers, running aio app deploy |
aio app build succeeds locally but fails in CI with an auth or "no project selected" error.
Cause: aio app build alone doesn't need Developer Console credentials, but if the workflow also runs aio app use or any aio console command, it needs a Console context that a fresh CI runner doesn't have.
Fix: Keep build/test/config-drift credential-free (they only need the checked-in app.commerce.config.ts and ext.config.yaml); only the deploy job should need auth โ see Step 3.
config-drift job is noisy โ it flags keys that are legitimately new and not yet documented in a step's Agent Prompt.
Cause: The check is comparing two things that are allowed to be briefly out of sync (config vs. prose), unlike sync-labs.mjs, which regenerates generated text from a single source of truth.
Fix: Treat config-drift failures as a prompt to update the relevant content/*.md page in the same PR, not as a hard merge blocker if the mismatch is a deliberate new key โ consider a --check (warn) mode on PRs and a stricter mode only on main, matching sync-labs.mjs's own apply-vs-check split.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
Open a PR that edits app.commerce.config.ts without updating a
corresponding Agent-Prompt reference โ the config-drift job fails with
a specific message naming the missing key, the same way
sync-labs.mjs --check reports what's stale without changing files.
Open a PR that breaks a handler test โ the test job fails and blocks
merge.
Merge to main โ the deploy job appears in the run but is held
pending approval on the stage environment; approving it runs
aio app deploy and the run log shows the same actions
aio runtime action list would show locally.
Secrets and credential handling in CI
Two real facts from the source repos should drive how you wire secrets,
rather than inventing a scheme: aio app use workspace-config.json --no-input --overwrite is what populates a local .env with
AIO_runtime_namespace and AIO_runtime_auth (confirmed in
partner-lab-app-management/.agents/skills/appbuilder-project-init/references/debugging.md),
and the downloaded workspace-config.json itself is git-ignored in that
repo because it "contains credentials" (per
partner-lab-app-management/workbook/02-initialize-project.md). Neither
repo shows a CI job actually authenticating โ this step generalizes
sensibly from those two facts rather than inventing exact secret names
Adobe hasn't documented for this workflow.
Update .github/workflows/app-ci.yml so the deploy job (Step 2) can
authenticate aio app deploy against the Stage workspace without committing
any credential file.
Do not commit workspace-config.json or .env โ both are already gitignored
in this pattern (see partner-lab-app-management/.gitignore) because they
carry real credentials.
Instead:
1. Store the two values aio app use would normally write locally โ
AIO_runtime_namespace and AIO_runtime_auth โ as encrypted GitHub Actions
repository secrets (Settings > Secrets and variables > Actions),
scoped to the "stage" environment created in Step 2 so only an approved
deploy run can read them.
2. In the deploy job, write them into a .env file at the start of the job
from ${{ secrets.AIO_RUNTIME_NAMESPACE }} and
${{ secrets.AIO_RUNTIME_AUTH }} (names chosen to mirror the .env keys
aio app use writes locally), then run aio app deploy.
3. Add a step that deletes the generated .env file after the deploy step
runs (rm -f .env), even on failure (if: always()), so the values never
persist in the runner's workspace artifact if one is later uploaded by
mistake.
Do not print these values to the workflow log at any point, and do not add
a build/test job that depends on them โ only the gated deploy job should
have access, per Step 2.
| Piece | Location |
|---|---|
| Repo secrets | AIO_RUNTIME_NAMESPACE, AIO_RUNTIME_AUTH โ scoped to the stage GitHub Environment |
| Generated at runtime | .env file written from secrets at the start of the deploy job, deleted at the end |
| Never committed | workspace-config.json, .env โ both already gitignored per partner-lab-app-management/.gitignore |
aio app deploy in CI fails with an auth error even though the secrets are set correctly.
Cause: AIO_runtime_auth tokens obtained via aio app use have a limited lifetime; a value copied once into a long-lived GitHub secret will eventually expire.
Fix: Treat this as an operational gap to flag, not silently work around โ neither source repo documents a service-account/long-lived-credential flow for headless aio auth in CI. Rotate the secret manually when deploys start failing, or investigate Adobe I/O's server-to-server / OAuth client-credentials options as a longer-lived alternative before relying on this for a production pipeline.
Cause: A debug/verbose flag on aio app deploy, or a shell step (e.g. env, cat .env) run for troubleshooting and left in the workflow.
Fix: Remove any command that dumps environment variables or file contents; rely on GitHub's automatic secret masking only as a backstop, not as the primary control.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
In the repo's Settings โ Environments, stage has
AIO_RUNTIME_NAMESPACE and AIO_RUNTIME_AUTH set as environment
secrets, not plain repository secrets โ so a workflow run outside the
approved environment can't read them.
A successful deploy run's log contains no plaintext value for either
secret (GitHub's log masking should show ***, but confirm the workflow
doesn't echo .env or pass -v/verbose flags that could leak it).
Checking out the repo fresh, .env and workspace-config.json are
absent and git status shows nothing generated by the workflow was
accidentally committed.
๐ค Blueprint with CDA
Blueprint-first App Builder in the hosted Commerce Developer Agent
Optional โ only if the schedule allows, otherwise take it home. You validated access to the hosted Commerce Developer Agent (CDA) back in Tooling (Step 3) but never actually used it. This lab does โ and it deliberately keeps the app tiny so you can focus on what CDA's hosted workflow adds, not on building the app itself (the other labs already cover generation depth).
CDA runs the same App Builder domain skills you installed locally
(commerce-app-eventing, commerce-app-webhooks, commerce-app-business-config,
commerce-app-storage, commerce-app-admin-ui) โ but hosted in the browser and
wrapped in a blueprint-first, verify-before-you-build, deploy-capable workflow.
Using a small, self-contained example, you'll watch CDA:
- interview you first, asking clarifying questions grounded in Commerce docs before it plans anything;
- draft an approval-gated, versioned blueprint (with an architecture diagram) that you refine into a v2 and then approve to lock;
- run an event-confirmation gate that checks your Commerce event is real, durable, and reachable before it will finalize the plan;
- generate and validate the code, then let you read the whole app in an in-browser Explorer;
- wire up Integrations โ connect App Builder and fill the project environment (Commerce and GitHub are optional);
- deploy to App Builder, live to your workspace, all from the browser; and
- when you want, continue the work outside CDA โ export a workspace ZIP or open a GitHub pull request.
The use case is intentionally simple. The focus of this lab is CDA's workflow โ the interview, the event grounding, the blueprint refinement, and the guided path to a real deployment โ not the app itself.
Scope: this lab uses CDA's App Builder track, which is the stable path. CDA also has an EDS / storefront track, but it is experimental โ poke at it on your own time, but don't rely on it for the workshop.
No local toolchain required โ everything here happens in the browser; nothing you build has to touch your machine.
Start a project and state your intent
Create a project in CDA, pick the App Builder track, and describe what you want. Rather than generating immediately, CDA interviews you first: it asks structured clarifying questions and grounds its answers in Adobe Commerce documentation (and your Migration Assessment report, if this org has one).
I'm building an Adobe Commerce App Builder app. Add asynchronous business
logic that reacts when a customer account is created.
Once a new customer is saved:
- Extract their email and when the account was created (use the event's own
timestamp, not the time your handler happens to run โ event delivery is
asynchronous and can lag).
- Simulate notifying an external "welcome" service using
https://aisenseapi.com/services/v1/webhook_capture:
- POST with an empty body to create a capture session (returns a
capture_id and an update_url).
- POST the customer's email and created-at timestamp to that update_url โ
that's the simulated outbound notification.
Rules:
- Only act on genuinely new customers, not on later profile or address
updates.
- This is a fire-and-forget notification โ don't add persistence or a dedup
mechanism for occasional duplicate calls on event redelivery.
- Return the capture_id as the action result (or a skipped result for
updates) โ that's the only way to confirm the call happened, since nothing
is persisted: fetching
https://aisenseapi.com/services/v1/webhook_capture/{capture_id} afterward
shows exactly what was sent.
Use the commerce-app-eventing skill for the event subscription wiring.
Ask me anything you need to pin the design down before you draft the
blueprint.
What CDA Will Ask
CDA won't write any code at this stage โ it interviews you first, then turns your answers into a blueprint and plan (not an app). It opens with a short set of multiple-choice questions, each offering an "Other โ write my own answer" option. Both the number of questions and their wording vary from run to run, so treat the following as examples of the kind of decisions it might probe for a use case like this, not a fixed script:
- Which event to subscribe to โ a single trade-off question tying scope and
new-vs-updated detection together:
observer.customer_register_success(storefront self-registration only โ this event fires only on new-account creation, never on profile/address edits, so no extra "is this new?" check is needed) vsobserver.customer_save_commit_after(any creation path โ storefront, Admin, REST/API โ but it fires on every customer save, so the handler has to infer "newly created" by comparingcreated_atvsupdated_at, which is less precise). - Delivery guarantee โ whether a failed notification call should retry, or just log and move on, since nothing is persisted to retry against later.
- Consumers โ whether anything else downstream needs to know about this notification (a CRM, a dashboard) or logging it is enough for now.
The event-confirmation gate
As it plans, CDA confirms your Commerce event before it finalizes the blueprint: it resolves the event against a ground-truth catalog of real ACCS events and posts an "Event selected" card (in the chat, and in the Blueprint and Plan panel) with:
| Field on the card | What it tells you |
|---|---|
| Schema reference key | The confirmed event โ customer_register_success if you picked storefront-only, or customer_save_commit_after if you picked "any creation path" โ a real, subscribable Commerce event either way, not a plausible-looking name the model invented. |
| Trigger context | Which surface the event resolves to fire from โ storefront for customer_register_success, or storefront/admin/api for customer_save_commit_after โ the reachability answer: CDA traced a live dispatch path for it. |
| Subscribed fields | The exact payload fields it will extract โ e.g. id, email, created_at (plus updated_at too if you picked customer_save_commit_after, to support the new-vs-updated comparison). |
| Why this event | A short rationale tying the choice to your requirements โ why this event (durable, covering the channels you picked) and, for customer_save_commit_after, how the handler compares created_at/updated_at so you welcome them only once. |
What CDA produces
When the interview and event confirmation are done, CDA hands you the real output of this step:
- a Blueprint โ the spec to build from: an Overview, the Architecture (diagram + rationale), and the confirmed event subscription; and
- a plan task list โ an ordered checklist (e.g. config โ event subscription โ handler โ tests) that will drive the Develop agent.
Watch for "Blueprint v1 ready" in the chat and the Blueprint and Plan panel filling in on the right, with an Approve plan button once it's ready. Refining and approving that blueprint is the next step.
Cause: Same as Tooling Step 3 โ you're in the wrong IMS org, or the org isn't entitled to the Commerce Migration Assessment application CDA is hosted under.
Fix: Switch orgs in the Adobe ID picker to match aio where, and if the
correct org still errors, flag it to your facilitator rather than troubleshooting
further on your own.
Cause: You picked the experimental EDS track at project creation.
Fix: Start a new project and choose the App Builder track โ the EDS track is experimental and out of scope for this lab.
Check it works
0/6Expand a check for verification steps, or mark confirmed if you already know it passes.
The org picker shows the same IMS org you confirmed with aio where in Tooling
Step 1.
CDA created a new project and it is scaffolding the App Builder starter, not the experimental EDS/storefront track.
Before drafting the blueprint, CDA asks at least one structured clarifying question (a short multiple-choice intake). Answer them, and CDA carries your answers into the plan โ you can see them echoed back under Questions Answered.
Open the Plan Tasks (or the blueprint Overview): they reference real Adobe App
Builder building blocks tied to the event you picked โ e.g. a task that adds
observer.customer_save_commit_after to app.commerce.config.ts's
eventing.commerce, with the real field names (email, created_at,
updated_at) mapped to a runtime action registered in ext.config.yaml, and
a handler task that specifically reads params.data.value per the App
Builder runtime convention โ not names the model made up. That's CDA
grounding the plan in Adobe Commerce knowledge, not free-associating.
An "Event selected" card confirmed the customer-creation event you picked
(customer_register_success or customer_save_commit_after) โ its
Trigger context, the Subscribed fields it needs (e.g. id, email,
created_at), and a Why this event rationale โ as part of producing the
blueprint. CDA confirmed the event and its fields, it didn't just name one. (You may
also see it revise a card after a grounding check โ that's the gate at work.)
When the interview is done, CDA drafts Blueprint v1 โ the spec for the app to build (Overview, Architecture, and the design rationale) โ together with a plan task list (e.g. config โ event subscription โ handler โ tests). That blueprint plus its tasks is the real output of this step: the spec to build from, and the checklist that will drive the Develop agent.
Refine the blueprint and approve the plan
You already have a first blueprint โ this step is where you refine it and lock it in. On the Blueprint tab, the Blueprint Preview sits beside a Blueprint and Plan panel with versioned drafts and an Approve plan button.
CDA offers two ways to refine it: one-click suggestion chips (Challenge assumptions, Find design gaps, โฆ) for common passes, or the "Ask to refine the blueprintโฆ" box for anything specific you want to change.
Each refinement CDA saves becomes a new, immutable version you can compare; when you're happy, Approve plan locks that version as the contract handed to the Develop phase.
Things to actually do on this page:
Read the architecture diagram (click it to expand) โ it lays out your design at a glance: the Commerce event flowing into the runtime action that calls the fake welcome service. The exact nodes and labels vary per run; if a piece is missing or wrong, refine the blueprint and watch it regenerate.
Make one concrete change. Paste this into the "Ask to refine the blueprintโฆ" box:
TEXTMake the URL configurable, use the value already provided as default.CDA runs a Create blueprint / Create tasks pass and posts a changelog of exactly what it changed and which plan tasks it revised versus carried forward unchanged. The Versions panel now lists v1 and v2 (Latest), each with its own Selected Version summary you can compare โ the refinement loop and version tracking in action.
Click Approve plan to lock the version you're viewing.
Cause: Only the latest version can be approved โ approval is meant to lock the newest agreed design as the contract.
Fix: Make sure the version you're viewing is the most recent one (create a fresh version if you've edited since), then approve that.
Cause: You already approved this version; an approved version is immutable so Develop can't be handed a moving target.
Fix: This is expected โ tell CDA to save a new version, make your edits there, and re-approve when you're happy.
Check it works
0/6Expand a check for verification steps, or mark confirmed if you already know it passes.
After your refinement, the Versions panel lists v1 and v2 (Latest), and
the v2 Overview / Selected Version summary names your change โ it leads with
something like "Made the webhook URL configurable (e.g. welcome_webhook_url),
defaulting to the webhook_capture URL already in useโฆ" โ and marks what carries
forward unchanged from v1. The Blueprint and Plan panel keeps each version's
summary so you can compare them: a surgical, tracked edit, not a silent overwrite.
In the middle Blueprint Preview pane, the Overview bullets now describe
your change (e.g. the webhook URL is a merchant-configurable Business Config
field instead of a hardcoded literal, read via @adobe/aio-commerce-lib-config
on every invocation, with the old literal kept only as a code-level fallback),
and the Plan Tasks count went up (e.g. from 5 to 9) to cover it โ new tasks
for the business config field itself, its extension-point registration, wiring
the handler/client to read it, and updated tests. The Overview also explicitly
calls out that everything from v1 carries forward unchanged, so v2 reads as an
addition, not a rewrite.
In the Blueprint and Plan panel, expand Questions Answered under the Audit Trail โ it lists every clarifying question CDA has asked across all versions so far, not just this refinement's, each paired with the answer you gave (e.g. the original event-choice question from Step 1, plus this step's question about where the webhook URL should be configured from). The exact questions, answers, and count will vary from run to run โ what matters is that every question you've answered so far shows up here, accumulating alongside the versions rather than resetting per refinement.
The Blueprint Preview's Architecture section rendered a diagram from the
blueprint content โ the customer-creation event you picked flowing into a
welcome-notification action (with a guard that skips merely-updated customers,
if you picked customer_save_commit_after โ not needed for
customer_register_success, which never fires on updates).
Maximize the diagram (the fullscreen icon in its bottom-left toolbar), then click through each node to open its detail panel on the right and confirm it's grounded in real specifics, not vague labels:
- Event (e.g.
observer.customer_save_commit_after) โ shows its Subscribed Fields and a Why This Event rationale explaining the trade-off against the alternative event you didn't pick. - Admin UI Extension (e.g. "Webhook Capture URL Setting") โ shows the exact Business Config field it adds (type, name, default value) so a merchant can override the endpoint from Commerce Admin without a redeploy.
- Runtime Action (e.g. "Handle Customer Created") โ shows a description plus numbered Processing Steps (check is-new โ extract fields โ create capture session โ post notification โ return result).
- External System (e.g. "AI Sense Webhook Capture") โ describes the simulated external service and its two-call flow.
You clicked Approve plan on the version you were viewing. CDA now treats it as the frozen contract for code generation โ try to edit a plan task and it tells you the version is locked (you'd have to create a new version to change the plan).
Generate, read, and validate the code
Approving the plan automatically moves CDA into its Develop phase โ no prompt needed. It scaffolds the workspace and builds the approved blueprint, tracking progress against the plan tasks as it goes. Your job on this page is to read the generated code in CDA's built-in Explorer and validate what it produced โ no deploy, no export yet (that comes once you wire up the integrations).
- Watch the Develop phase generate the eventing config and handler action from the approved plan. It starts on its own the moment you approve โ you don't ask it to build.
- Read the code in the Explorer. Switch to the Develop tab's Explorer
and open the generated files โ the handler action under
src/โฆ/actions/, its co-generated test file, andapp.commerce.config.ts. This is the whole app, in the browser, before anything is deployed or exported. - Trace the plan into the code. Confirm the pieces the interview and blueprint
called for actually landed: the event subscription to whichever event you
picked, the new-customer guard (the
created_at/updated_atcomparison, only present if you pickedcustomer_save_commit_after), and the notification call to the fake welcome service. - Validate the generated code. Click the Validate workspace pill above the chat input (or ask CDA to validate the workspace in chat). CDA runs the workspace validation and reports what passed and anything it needs to fix โ if it flags something, have it fix the issue and re-validate rather than trusting the generation blindly. The Validate panel streams the build, ending in "Generated N action(s)โฆ Builtโฆ โ Validation passed."
Cause: Expected โ generation is high-quality scaffolding, not guaranteed production-ready code; validation exists precisely to catch what slipped through.
Fix: Don't hand-patch it in a panic โ tell CDA what validation reported and have it fix the workspace, then re-run Validate workspace until it's clean. That fix-and-revalidate loop is the point of the step.
Cause: Validation proves the code builds and type-checks โ it doesn't diff the generated code against your approved blueprint or plan tasks, so a piece can drift from the plan and still validate clean.
Fix: Tell CDA in chat exactly what's wrong or missing (name the plan task or blueprint detail it should match) and have it correct the workspace, then re-trace the plan into the code and re-run Validate workspace.
Check it works
0/5Expand a check for verification steps, or mark confirmed if you already know it passes.
Open app.commerce.config.ts in the Explorer and confirm an
eventing.commerce subscription to the customer-creation event you picked
(customer_register_success or customer_save_commit_after), pointing at the
welcome handler.
A businessConfig.schema entry for the v2 webhook URL, e.g.:
{
name: "webhook_capture_base_url",
type: "url",
default: "https://aisenseapi.com/services/v1/webhook_capture",
}
Exact name/label vary by run โ use what your agent produced in the checks
below.
Open the generated handler action (under src/โฆ/actions/): if you picked
customer_save_commit_after, it guards on new-vs-updated (comparing
created_at/updated_at) so it only welcomes genuine registrations โ
customer_register_success needs no such guard, since the event itself only
fires on new accounts. Either way, it then calls the fake welcome service and
returns the capture_id (or a skipped result). The plan you approved is
what Develop actually built, not a drifted or reinvented version.
In the Explorer, click Search (in the Explorer header) and search for the
business config field name from check #2 (e.g. webhook_capture_base_url) โ it
should turn up not just in app.commerce.config.ts, but also in the handler
action and its tests, where it replaces the hardcoded URL. Declaring the
field isn't enough on its own; this confirms the blueprint change you requested
actually landed in the code that runs.
You ran Validate workspace (via the pill or a chat prompt) and CDA reported Validation passed โ or you had it fix what it flagged and re-validated until it did.
Configure App Builder and the project environment
Everything so far โ blueprint, generate, validate, read the code โ needed no external wiring. To deploy, CDA has to connect to your Adobe org. The Develop tab hints at this with a banner:
App Builder integration incomplete โ the agent cannot auto-deploy or run
aioCLI tools until the workspace and OAuth secrets are saved ยท Open Integrations
Open the Integrations tab (top right). It's a four-part wizard with an Environment Readiness panel tracking "N/6 required checks complete" up to Deployment Ready. Only two parts matter for a deploy โ App Builder and Project Env. The other two are optional: skip Commerce (not needed here), and skip GitHub for now (you'll set it up later, only if you want to work outside CDA).
App Builder (required โ it's what makes a deploy possible) โ connect or create the App Builder workspace. The recommended path, Create a new App Builder project:
- Pick the Organization and enter a Project Title, then Create App
Builder Project; when prompted, choose a workspace to set up (e.g.
Stage). CDA provisions the project, credentials, and most of the required API subscriptions automatically. - Add the one API CDA can't. The Required APIs list flags Adobe Commerce as a Cloud Service with "Requires product profile selection โ add in Developer Console." CDA can't add this one for you because it carries product profiles that must be chosen when you enable it. Follow the link to the Adobe Developer Console, add that API to the workspace, and select the product profile(s).
- Back in the Developer Console's left sidebar, under Workspaces, click
Workspace overview โ not the API's own page (e.g. Adobe Commerce as a
Cloud Service) you were just on to add the product profile. The
workspace.jsondownload lives on the overview page, not the API page. - On the Workspace overview page, click Download all (top right) to get
workspace.jsonfor that workspace (it includes the client secret), then import it into CDA โ drag-and-drop or Browse File. CDA applies the credentials. - Click Re-check status on Required APIs. Once it reads All APIs subscribed, the readiness panel shows App Builder Connected, Required APIs Enabled, Credentials Validated, and Secrets Stored green.
(Already have a Developer Console project? Use Connect existing workspace instead and import its
workspace.json.)- Pick the Organization and enter a Project Title, then Create App
Builder Project; when prompted, choose a workspace to set up (e.g.
Project Env (fill it so the deployed app is configured) โ the Project Environment step lists the env vars CDA detected from the generated app and how many are set (the count is app-specific โ often just a
LOG_LEVEL). Fill whatever the app declares so it has the values it needs at runtime.
Connecting App Builder is the real gate on deploying โ it gives the deploy a target (project / workspace / runtime namespace). You can deploy before Project Env is complete, but any values the app needs at runtime will be missing, so the deployed app can be misconfigured. Fill Project Env before you rely on it. (Deploying at all is optional for this lab โ the goal is understanding what CDA needs to go live.)
Cause: That API is the one CDA can't provision automatically โ it needs a
product profile selected when it's added. Either it wasn't added in the
Developer Console, no product profile was picked, or you re-checked before
re-importing an updated workspace.json.
Fix: In the Developer Console workspace, add Adobe Commerce as a Cloud
Service and select the product profile(s); re-download workspace.json (via
Download all on the Workspace overview page), re-import it into CDA,
then Re-check status again โ it should read All APIs subscribed.
Cause: The file you dropped isn't a workspace export โ it's some other JSON (a
project-level download, an unrelated config, etc.). CDA needs the workspace.json
for the specific workspace, which carries its credentials and client secret.
Fix: In the Adobe Developer Console, open the specific workspace's
Workspace overview (e.g. Stage) and click Download all to get
workspace.json, then import that exact file โ not a project export or any
other JSON.
Cause: Deployment Ready tallies several checks โ App Builder Connected, Required APIs Enabled, Credentials Validated, Secrets Stored, and the Project environment values; a missing project-env value keeps it below the bar.
Fix: Finish the App Builder step and fill the remaining Project Env values until the panel reads Deployment Ready.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
You created (or connected) an App Builder workspace, added Adobe Commerce as a
Cloud Service with a product profile in the Developer Console, imported the
workspace.json, and hit Re-check status โ Required APIs reads All APIs
subscribed.
The Project Env step no longer shows Needs attention โ you entered the values the app declares.
With App Builder connected and Project Env filled (Commerce and GitHub stay skipped), the Environment Readiness panel reaches Deployment Ready / all required checks complete. You don't have to deploy yet โ just confirm the panel gets there.
Deploy to App Builder
With Deployment Ready green in the Environment Readiness panel โ and the
"App Builder integration incomplete" banner from the previous step now gone โ you
can push the app live from the browser, no local aio needed. This is the payoff:
an app you designed, generated, validated, and now deploy without ever leaving CDA.
- From the Develop tab, click Deploy to App Builder (or just ask CDA to deploy in chat).
- CDA won't ship silently. It shows a Confirm deployment card listing the exact
target it will run
aio app deployagainst โ Organization, Project, Workspace, and Runtime namespace (e.g. WorkspaceStage, namespaceโฆ-stage). Review it, then confirm by clicking Deploy to(e.g. Deploy to Stage). - Watch it run in the Deploy panel โ the live deploy log at the bottom of the Develop tab, next to Validate and Console. CDA replies "Deploy started to Stage. I'll watch for the deploy resultโฆ" and the log streams the phases: Running pre-deploy validation (30โ180s)โฆ โ Bootstrapping deploy containerโฆ โ Syncing filesโฆ โ npm installโฆ โ Updating Extension Points in Workspace=Stageโฆ, ending at Successful deployment ๐ / โ Deploy succeeded with a green Deployed banner.
- To exercise it end to end, reinstall/associate the app in Commerce Admin โ
Apps โ App Management, then register a new customer (storefront or Admin),
grab the
capture_idfrom the handler's Runtime activation logs, and fetchhttps://aisenseapi.com/services/v1/webhook_capture/{capture_id}to see the exact payload that reached the external service.
Cause: The App Builder workspace isn't connected, so CDA has no target
(project / workspace / runtime namespace) to run aio app deploy against.
Fix: Complete the App Builder step in Integrations (create or import a workspace) so the deploy has a target โ and fill Project Env so the deployed app has the runtime values it needs.
Cause: A build- or deploy-time issue surfaced โ a compile error, a bad config value, or a transient failure.
Fix: Ask CDA in chat to look at the failure and fix it, then redeploy โ it can read the deploy log, patch the workspace, and re-run the deploy. Don't hand-patch blindly.
Cause: The app was deployed but never (re)associated and installed in Commerce Admin, so the event subscription isn't live on the workspace โ and Commerce eventing is asynchronous.
Fix: Reassociate and reinstall the app in Commerce Admin โ Apps โ App
Management, then re-check the activation logs
for the capture_id, and fetch the webhook_capture read endpoint to confirm
the payload arrived.
Cause: The app was deployed with Project Env incomplete, so a runtime value the code expects is missing. Deploy is gated only on App Builder, so this can ship a misconfigured app.
Fix: Fill the remaining values on Integrations โ Project Env, then redeploy.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
CDA showed a Confirm deployment card naming the Organization / Project /
Workspace / Runtime namespace it would run aio app deploy against; after you
confirmed (Deploy to Stage), the deploy ran in CDA's hosted container and the
Deploy panel ended with Successful deployment / โ
Deploy succeeded.
The Deploy log registered the app's extension points in the workspace (e.g. New
Extension Point(s) in Workspace 'Stage': commerce/extensibility/1), and the
deployed runtime action and its eventing subscription are present in your App
Builder workspace (confirm via aio runtime action list against that workspace if
you like).
Reinstall/associate the app, then register a new customer โ aio runtime activation list (and aio runtime activation logs <id>) for the welcome
handler shows a result like { "capture_id": "...", "processed": true }.
Recover the exact payload that was sent by fetching that capture:
curl -s https://aisenseapi.com/services/v1/webhook_capture/<capture_id>
The response's request.body.json shows the email and created_at your
handler POSTed โ confirming the call actually reached an external HTTP
endpoint, not just that your code believes it did (captures expire after 24h,
per the response's expire_datetime, so recover it soon after the run).
Editing an existing customer instead returns { "skipped": true } โ the
guard skipped it, so there's no capture to recover.
Configure the GitHub integration
This step is optional โ do it only if you want to work on the app outside CDA: push the code to your own repository and open pull requests. Staying inside CDA? Skip ahead. Setting it up here lets the next step use it.
Open Integrations โ GitHub and connect a repository:
- Enter the GitHub Repository URL.
- Choose a connection: the GitHub App (recommended โ org-wide access, no token to rotate, an admin installs it once) or a Personal Access Token (no admin โ use it if the App isn't installed or your org wants a security review first).
- When it's linked, the step shows Connected ยท GitHub App ยท
with a Disconnect option.
Back on the Develop tab, the GitHub icon on the Explorer header โ red before โ is now ringed in green, meaning the workspace is connected to your repo.
Cause: No repository is connected yet, or the connection wasn't completed (repo URL missing, or the GitHub App / token step wasn't finished).
Fix: In Integrations โ GitHub, enter the repository URL and complete the GitHub App (an admin installs it once) or Personal Access Token connection; the icon turns green once it's linked.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
The GitHub step shows Connected against your repository URL (via the GitHub App or a Personal Access Token).
On the Develop tab, the GitHub icon on the Explorer header is ringed green (it was red before connecting).
Continue the work outside CDA
CDA is a great place to blueprint, generate, and deploy โ but what it produced is a normal App Builder project, and you'll often want to keep working on it in your own toolchain or hand it to a team. There are two ways out, both from the Develop tab. Neither is required (the app is already deployed) โ this is about continuing the work.
Option A โ Download a ZIP. On the Explorer header row (top of the code
pane), click the download (โ) button next to the GitHub icon. It downloads the
whole workspace as a ZIP โ open it in any editor or a local aio toolchain and
carry on offline. (No GitHub connection needed.)
Option B โ Open a GitHub pull request. With GitHub connected from the previous step, the Explorer header's GitHub icon is green. Click it to open the GitHub popup, then:
- Click Create PR, fill in a Branch name (e.g.
feat/welcome-new-customers) and a PR title, then Create PR. CDA pushes the workspace to that branch and opens the pull request โ it never auto-merges; you review and merge on GitHub. - The same popup also has GitHub Sync: pick a Branch and Sync from GitHub to pull changes made outside CDA back into the workspace, so you can round-trip between CDA and your repo.
Cause: The GitHub App is installed for "Only select repositories" and your target repo isn't in that list (or a PAT lacks repo write scope), so CDA can't push a branch.
Fix: It depends on how you connected. GitHub App โ add the target repository under GitHub โ Settings โ Applications โ [the installed CDA app] โ Repository access (or choose All repositories) and Save. Personal Access Token โ recreate the token with write access to the repo's contents and pull requests, then reconnect it in Integrations โ GitHub. Either way, retry Create PR afterward.
Cause: The branch name you entered collides with an existing branch on the repo.
Fix: Use a fresh branch name and create the PR again.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
Either the โ button downloaded a workspace ZIP you can open locally, or you opened a pull request from the green GitHub popup.
Using Create PR with a branch name and PR title, CDA pushed the workspace and opened a pull request on your repository โ waiting for your review, not auto-merged.
๐ Optional Exercises
Take these home โ not part of the live day
None of this lab is part of the live worklab agenda. Extend Business
Logic covers four extension points end to end: a before
webhook, a native Commerce event, App Builder DB Storage, and one Admin UI
SDK menu page. That's deliberately not everything the SDK offers โ the live
day is scoped to fit in one day, not to be exhaustive.
Each step below targets an extension point (or a variant of one) the live
path doesn't touch, and builds on the same app from Extend Business
Logic rather than starting over โ you'll be extending the
order-hold, sync-order-to-erp, and erp-sync-ui/get-erp-sync-status
pieces you already built. Do these on your own time, in any order, or skip
the ones that don't interest you โ there's no gating, no "Confirm" pressure
here beyond your own curiosity.
Mass action: force an ERP resync from the order grid
The live Admin step only demonstrates the Admin UI SDK menu extension
point. This step exercises mass actions (worker variant): a bulk
action on the order grid that re-triggers the ERP sync for selected orders,
bypassing the idempotency check sync-order-to-erp already enforces.
Add a mass action to the Commerce Admin order grid, using the
commerce-app-admin-ui skill's massActions (worker variant) extension point.
Label: "Force ERP Resync". Add a protected runtime action, e.g.
force-resync-erp-orders, that:
- Accepts the selected order ids from the mass-action worker request.
- For each selected order, re-fetches a random ERP id from the same
erp_sync_api_url business config used by sync-order-to-erp, and upserts
(not just inserts) the erp-sync ABDB record for that order_id, overwriting
any existing erp_id/synced_at.
- Skips (does not error) any order currently in "holded" state, same
hold-aware rule as sync-order-to-erp.
Reuse the pure-logic module from sync-order-to-erp where it applies (ERP id
parsing, record shape) rather than duplicating it. Build and deploy when
ready.
| Piece | Location |
|---|---|
| Mass action registration | app.commerce.config.ts -> adminUi.order.massActions[] |
| Handler | src/.../actions/force-resync-erp-orders/index.js (worker, protected) |
| Shared logic | reused from sync-order-to-erp's pure-logic module |
| Data source | erp-sync ABDB collection, upsert instead of insert-only |
Cause: require-adobe-auth: true alone isn't enough for a worker mass
action โ Commerce calls it with an IMS token, and the action registration
itself needs final: true so the bound inputs stay locked, same as any
other Admin UI SDK worker action.
Fix: Confirm the action's annotations match the commerce-app-admin-ui
skill's worker-variant example exactly (require-adobe-auth: true,
final: true), and that it's declared under its own package in
src/commerce-backend-ui-2/ext.config.yaml, not the extensibility
extension's ext.config.yaml where the event/webhook actions live.
Cause: Reused insertOne instead of an upsert-capable write
(updateOne/findOneAndUpdate with upsert: true) against the erp-sync
collection.
Fix: Switch the write to an upsert keyed on order_id, and remember
aio-lib-db's findOne throws instead of returning null when nothing
matches (see Extend Business Logic, Step 3) โ the "does a record already
exist" check needs the same try/catch treatment here.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
erp-sync record (confirm via
aio app db document find erp-sync '{}'), select both in the order grid,
and run "Force ERP Resync".
aio app db document find erp-sync '{}' โ both records now show a
new erp_id and a newer synced_at, not a duplicate row.
Order view button: view this order's ERP sync status
The Admin UI SDK page from Extend Business Logic lists every order. This step adds a view-type order view button โ a button on a single order's page that opens an iframe scoped to just that order, exercising a different Admin UI SDK context hook.
Add a view-type order view button to this app, using the
commerce-app-admin-ui skill's viewButtons (view variant) extension point.
Label: "View ERP Sync Status". On click, open an iframe page that:
- Reads the order id from useOrderViewButtonContext() (not a route param).
- Calls the existing erp-sync-ui/get-erp-sync-status action with that
order_id as a query filter, the same way the ERP Sync Status menu page
already does (same getActionUrl()/config.json pattern, same
x-gw-ims-org-id + Authorization headers).
- Shows the single matching record (or "not synced yet") and a button that
calls useHostConnection().actions.close() to return to the order.
Register the new route in src/commerce-backend-ui-2/web-src/src/app.tsx
alongside the existing index route โ do not modify the existing menu page's
route. Build and deploy when ready.
| Piece | Location |
|---|---|
| View button registration | app.commerce.config.ts -> adminUi.order.viewButtons[] |
| New route + page | web-src/src/app.tsx (new { path, element } entry) + web-src/src/pages/*.tsx |
| Data source | existing erp-sync-ui/get-erp-sync-status action โ no new action needed |
get-erp-sync-status returns 401 even though
the button opens fine and useIms() resolves.
Cause: require-adobe-auth: true rejects requests missing the
x-gw-ims-org-id header โ a valid bearer token alone isn't enough. This bit
the ERP Sync Status menu page too during the live build.
Fix: Send both Authorization: Bearer <imsToken> and
x-gw-ims-org-id: <imsOrgId> (both available from useIms()'s data).
Cause: The registered path in app.commerce.config.ts and the route's
path in app.tsx don't match character-for-character (including the
leading #/).
Fix: Copy the path string verbatim between the config entry and the
app.tsx route โ don't retype it in either place.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
erp-sync record. Click "View ERP Sync
Status" โ the iframe shows exactly that order's erp_id/synced_at,
not the whole list.
Grid column: an "ERP Sync Status" badge on the order grid
Steps 1 and 2 both open something. This step surfaces status inline,
without opening anything โ the one Admin UI SDK extension point
(gridColumns) the live path never touches.
Add a custom grid column to the Commerce Admin order grid, using the
commerce-app-admin-ui skill's gridColumns extension point.
Column label: "ERP Sync Status". Add a protected worker action that, given
the grid's requested order ids, returns one of "Synced", "Pending", or
"Held" per order โ "Held" when the order's current state is holded,
"Synced" when an erp-sync record exists for that order_id, "Pending"
otherwise. Row keys in the response must match the column id declared in
config exactly.
Build and deploy when ready.
| Piece | Location |
|---|---|
| Grid column registration | app.commerce.config.ts -> adminUi.order.gridColumns |
| Handler | src/.../actions/order-erp-status-column/index.js (worker, protected) |
| Data sources | erp-sync ABDB collection + order state (from the grid request) |
aio rt activation logs).
Cause: The response's row keys don't match the id declared in
gridColumns.columns โ grid columns render strictly by key match, falling
back to a defaults bag otherwise.
Fix: Make sure the key you set in the response object is character-for-
character the same as the column's id in config.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
aio app db document find erp-sync '{}' and the order's actual state
say directly.
External event: a simulated ERP callback
Extend Business Logic's event step only wires up a native Commerce
event (eventing.commerce). This step exercises the other half of
commerce-app-eventing โ an external event, simulating the ERP calling
back asynchronously to confirm a sync (as a real ERP integration would,
rather than our fake synchronous stub).
Add an external event source to this app, using the commerce-app-eventing
skill's eventing.external extension point.
Event name: erp.sync_confirmed (free-form external event name, not a
Commerce plugin./observer. name). Add a protected runtime action that
receives { order_id, erp_id, confirmed_at } in the event payload and
updates the matching erp-sync ABDB record's status to "confirmed" (add a
status field to the schema if it doesn't have one, defaulting existing rows'
absence of status to an implicit "pending").
Note: external events don't support the `fields` extraction array the way
Commerce events do โ the handler receives the full payload on params.data.
Build and deploy when ready.
| Piece | Location |
|---|---|
| External event registration | app.commerce.config.ts -> eventing.external[] |
| Handler | src/.../actions/erp-sync-confirmed/index.js |
| Data source | erp-sync ABDB collection, adding a status field |
app.commerce.config.ts validation fails with a fields-
related error on the external event.
Cause: fields is a Commerce-event-only property; external events
don't support field extraction at all โ the handler always receives the
full payload.
Fix: Remove fields from the external event entry entirely.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
order_id/erp_id from an
existing erp-sync record.
aio app db document find erp-sync '{}' shows that record's status
updated to "confirmed".
An "after" webhook with a response tweak
Extend Business Logic's webhook step only uses a before (blocking) webhook. This step uses an after webhook โ observing rather than blocking โ to append an internal order comment once an order clears the approval threshold from Step 2 of that lab.
Add a second webhook to this app, using the commerce-app-webhooks skill,
of webhook_type "after" on observer.sales_order_save_commit_after (the
same event already used for order-hold and ERP sync โ webhooks and events
can both subscribe to it independently).
When the order's grand_total exceeds the existing order_approval_threshold
business config value, have the webhook response add an internal order
comment (not visible to the customer) noting it required manual approval.
An "after" webhook can't block the save the way a "before" one can โ don't
attempt a fallback_error_message or op: "exception" here, only an
observational response.
Build and deploy when ready.
| Piece | Location |
|---|---|
| Webhook registration | app.commerce.config.ts -> webhooks[], webhook_type: "after" |
| Handler | src/.../actions/note-large-order/index.js |
| Shared config | reuses order_approval_threshold from Extend Business Logic, Step 2 |
Cause: after webhooks are observational โ Commerce doesn't do
anything with an op: "exception" or fallback_error_message response
there the way it does for before hooks. A handler written like a
before hook (expecting to block) will just silently have no visible
effect.
Fix: Have the handler's success path perform the actual side effect
(here, calling Commerce's REST API to add the order comment) rather than
returning a response Commerce would only act on for a before hook.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.
after
webhook never blocks the save).
Business config: field types the live lab doesn't use
Extend Business Logic only ever declares text and url business config
fields. This step tries two more of commerce-app-business-config's
supported types.
Add two fields to this app's businessConfig.schema, using the
commerce-app-business-config skill:
1. erp_sync_enabled โ type boolean, default true. When false,
sync-order-to-erp should skip entirely (return synced: false, reason:
"disabled") without calling the ERP API or touching the erp-sync
collection at all.
2. erp_approver_emails โ type list, a list of email addresses (no default
needed). Not consumed by any action yet โ just get it configurable and
visible in Commerce Admin.
Remember config values may arrive as strings even for a boolean field โ
coerce explicitly rather than trusting the type. Build and deploy when
ready.
| Piece | Location |
|---|---|
| New fields | app.commerce.config.ts -> businessConfig.schema |
| Gate check | sync-order-to-erp's handler, before the ERP API call |
erp_sync_enabled is set to false in Commerce Admin, but the
sync still runs.
Cause: The config library can return the boolean's underlying value as
the string "false" rather than the boolean false, depending on how the
schema field resolves it โ a truthy check on the raw value passes even when
it "looks" false.
Fix: Coerce explicitly (e.g. compare against the string "false", or
use the same numeric/string coercion pattern already used for
order_approval_threshold) instead of relying on JS truthiness.
Check it works
0/3Expand a check for verification steps, or mark confirmed if you already know it passes.
erp_sync_enabled off, place a large order,
and confirm aio app db document find erp-sync '{}' gets no new record.
erp_approver_emails accepts a real list of emails in Commerce Admin
and persists across a save/reload.
Compliance pass with the app-review skill
Less a new feature, more a wrap-up: run this workshop's finished app through the same compliance check a real Exchange submission would face.
Run the app-review:commerce-app-review skill against this project, as if
preparing it for submission to Adobe Exchange. Walk through every MUST-have
and NICE-to-have finding interactively, and fix any MUST-have blocker it
reports.
| Piece | Location |
|---|---|
| N/A โ this step audits the existing app | whole project |
Cause: This workshop's app was built step by step through agent prompts without a pass dedicated to documentation โ expected for a training app, but exactly what a real submission review would catch too.
Fix: Ask your agent to generate the missing documentation from the
app's actual app.commerce.config.ts and ext.config.yaml content, then
re-run the review.
Check it works
0/2Expand a check for verification steps, or mark confirmed if you already know it passes.