diff --git a/.gitignore b/.gitignore
index fd4f2b066b..84888f4ca4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules
.DS_Store
+.superpowers
diff --git a/app.js b/app.js
deleted file mode 100644
index 5ab128e4b4..0000000000
--- a/app.js
+++ /dev/null
@@ -1,61 +0,0 @@
-const express = require("express");
-const app = express();
-const port = process.env.PORT || 3001;
-
-app.get("/", (req, res) => res.type('html').send(html));
-
-const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`));
-
-server.keepAliveTimeout = 120 * 1000;
-server.headersTimeout = 120 * 1000;
-
-const html = `
-
-
-
- Hello from Render!
-
-
-
-
-
-
-
-
-`
diff --git a/data/express-templates.json b/data/express-templates.json
new file mode 100644
index 0000000000..79fb4b86fc
--- /dev/null
+++ b/data/express-templates.json
@@ -0,0 +1,3 @@
+[
+ { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" }
+]
diff --git a/docs/superpowers/plans/2026-07-13-express-edit-flow.md b/docs/superpowers/plans/2026-07-13-express-edit-flow.md
new file mode 100644
index 0000000000..b36d079cae
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-13-express-edit-flow.md
@@ -0,0 +1,705 @@
+# Express Image Edit Flow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let a WhatsApp user ask what edits are allowed on a previously-sent graphic, request an edit, have it validated against the graphic's unlocked layers, applied via a mocked Adobe Express API, uploaded via a mocked Meta upload, and re-sent as an updated image — all with placeholder Express/Meta calls that can be swapped for real ones later.
+
+**Architecture:** Three new small, dependency-free modules (`imageStore.js` for per-phone-number tracked-image state, `expressApi.js` for mock template/edit calls, `metaUpload.js` for the mock media upload) get unit tests via Node's built-in test runner. A fourth new module, `actions.js`, extracts all GPT tool-call action handlers (including the two existing ones, `list_campaign_graphics` and `generate_bulk_graphics`, moved unchanged) so the new `edit_graphic` logic can be unit-tested with a fake `sendImage` — no real WhatsApp/OpenAI credentials needed for tests. `app.js` is left as the thin orchestrator: WhatsApp API calls, GPT tool schemas/system prompt, and the webhook route wiring args into `actions.js`.
+
+**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` (no new dependencies).
+
+## Global Constraints
+
+- No real Adobe Express or Meta API calls — all such calls are placeholder/mock functions returning mock values (per spec, these get swapped for real implementations later).
+- Actually sending the initial graphic image is out of scope — tracked images are pre-seeded in memory at first access per phone number (spec: 3 fixed seed entries: Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster).
+- State is in-memory only, no persistence across restarts (matches existing `conversationHistory` pattern in app.js).
+- No new npm dependencies — use Node's built-in `node:test` test runner.
+- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons.
+
+---
+
+## Task 1: `imageStore.js` — tracked-image state
+
+**Files:**
+- Create: `imageStore.js`
+- Test: `imageStore.test.js`
+- Modify: `package.json` (add a `test` script)
+
+**Interfaces:**
+- Produces: `getTrackedImages(phoneNumber) -> Array<{ id: string, name: string, templateId: string, currentEdits: object }>` (seeds 3 fixed entries on first call for a phone number, returns the same array reference on later calls).
+- Produces: `findTrackedImage(phoneNumber, imageId) -> object | undefined`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `imageStore.test.js`:
+
+```js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { getTrackedImages, findTrackedImage } = require('./imageStore');
+
+test('getTrackedImages seeds 3 images on first access', () => {
+ const images = getTrackedImages('111');
+ assert.equal(images.length, 3);
+ assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']);
+ assert.deepEqual(images[0].currentEdits, {});
+});
+
+test('getTrackedImages returns the same array on repeated calls for the same phone number', () => {
+ const first = getTrackedImages('222');
+ first[0].currentEdits.headline = 'Flash Sale';
+ const second = getTrackedImages('222');
+ assert.equal(second[0].currentEdits.headline, 'Flash Sale');
+});
+
+test('getTrackedImages seeds independently per phone number', () => {
+ getTrackedImages('333')[0].currentEdits.headline = 'Only for 333';
+ const other = getTrackedImages('444');
+ assert.deepEqual(other[0].currentEdits, {});
+});
+
+test('findTrackedImage returns the matching image', () => {
+ getTrackedImages('555');
+ const image = findTrackedImage('555', 'img_2');
+ assert.equal(image.name, 'Summer Sale Flyer');
+});
+
+test('findTrackedImage returns undefined for an unknown id', () => {
+ getTrackedImages('666');
+ const image = findTrackedImage('666', 'img_999');
+ assert.equal(image, undefined);
+});
+```
+
+- [ ] **Step 2: Add the test script and run to verify failure**
+
+Modify `package.json` scripts section to:
+
+```json
+"scripts": {
+ "start": "node app.js",
+ "test": "node --test"
+},
+```
+
+Run: `node --test imageStore.test.js`
+Expected: FAIL — `Cannot find module './imageStore'`
+
+- [ ] **Step 3: Write the implementation**
+
+Create `imageStore.js`:
+
+```js
+const SEED_IMAGES = [
+ { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' },
+ { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' },
+ { id: 'img_3', name: 'New Arrival Poster', templateId: 'tpl_newarrival' },
+];
+
+const trackedImages = new Map();
+
+function getTrackedImages(phoneNumber) {
+ if (!trackedImages.has(phoneNumber)) {
+ trackedImages.set(
+ phoneNumber,
+ SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} }))
+ );
+ }
+ return trackedImages.get(phoneNumber);
+}
+
+function findTrackedImage(phoneNumber, imageId) {
+ return getTrackedImages(phoneNumber).find((image) => image.id === imageId);
+}
+
+module.exports = { getTrackedImages, findTrackedImage };
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node --test imageStore.test.js`
+Expected: PASS — 5 tests passing
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add imageStore.js imageStore.test.js package.json
+git commit -m "feat: add in-memory tracked-image store"
+```
+
+---
+
+## Task 2: `expressApi.js` — mock Adobe Express calls
+
+**Files:**
+- Create: `expressApi.js`
+- Test: `expressApi.test.js`
+
+**Interfaces:**
+- Consumes: nothing (standalone module).
+- Produces: `getTemplateInfo(templateId) -> { templateId: string, unlockedLayers: string[] }`.
+- Produces: `applyEdit(templateId, currentEdits, newEdits) -> { mergedEdits: object, renderedImageUrl: string }`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `expressApi.test.js`:
+
+```js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { getTemplateInfo, applyEdit } = require('./expressApi');
+
+test('getTemplateInfo returns the unlocked layers for a known template', () => {
+ const info = getTemplateInfo('tpl_diwali');
+ assert.deepEqual(info, {
+ templateId: 'tpl_diwali',
+ unlockedLayers: ['discount_text', 'headline', 'background_color'],
+ });
+});
+
+test('getTemplateInfo returns an empty layer list for an unknown template', () => {
+ const info = getTemplateInfo('tpl_does_not_exist');
+ assert.deepEqual(info.unlockedLayers, []);
+});
+
+test('applyEdit merges new edits on top of current edits', () => {
+ const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' });
+ assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' });
+});
+
+test('applyEdit returns a rendered image url that references the template', () => {
+ const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' });
+ assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node --test expressApi.test.js`
+Expected: FAIL — `Cannot find module './expressApi'`
+
+- [ ] **Step 3: Write the implementation**
+
+Create `expressApi.js`:
+
+```js
+const TEMPLATE_LAYERS = {
+ tpl_diwali: ['discount_text', 'headline', 'background_color'],
+ tpl_summer: ['headline', 'font_color'],
+ tpl_newarrival: ['headline'],
+};
+
+function getTemplateInfo(templateId) {
+ return {
+ templateId,
+ unlockedLayers: TEMPLATE_LAYERS[templateId] || [],
+ };
+}
+
+let renderRevision = 0;
+
+function applyEdit(templateId, currentEdits, newEdits) {
+ const mergedEdits = { ...currentEdits, ...newEdits };
+ renderRevision += 1;
+ return {
+ mergedEdits,
+ renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`,
+ };
+}
+
+module.exports = { getTemplateInfo, applyEdit };
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node --test expressApi.test.js`
+Expected: PASS — 4 tests passing
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add expressApi.js expressApi.test.js
+git commit -m "feat: add mock Adobe Express API module"
+```
+
+---
+
+## Task 3: `metaUpload.js` — mock Meta media upload
+
+**Files:**
+- Create: `metaUpload.js`
+- Test: `metaUpload.test.js`
+
+**Interfaces:**
+- Consumes: nothing (standalone module).
+- Produces: `uploadImageToMeta(renderedImageUrl) -> Promise` (mock CDN URL).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `metaUpload.test.js`:
+
+```js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { uploadImageToMeta } = require('./metaUpload');
+
+test('uploadImageToMeta returns a mock CDN url', async () => {
+ const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1');
+ assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/);
+});
+
+test('uploadImageToMeta returns a different url on each call', async () => {
+ const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1');
+ const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2');
+ assert.notEqual(first, second);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node --test metaUpload.test.js`
+Expected: FAIL — `Cannot find module './metaUpload'`
+
+- [ ] **Step 3: Write the implementation**
+
+Create `metaUpload.js`:
+
+```js
+const { randomUUID } = require('node:crypto');
+
+async function uploadImageToMeta(renderedImageUrl) {
+ return `https://mock-meta-cdn.local/media/${randomUUID()}.png`;
+}
+
+module.exports = { uploadImageToMeta };
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node --test metaUpload.test.js`
+Expected: PASS — 2 tests passing
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add metaUpload.js metaUpload.test.js
+git commit -m "feat: add mock Meta media upload module"
+```
+
+---
+
+## Task 4: `actions.js` — extract and extend GPT tool-call action handlers
+
+**Files:**
+- Create: `actions.js`
+- Test: `actions.test.js`
+
+**Interfaces:**
+- Consumes: `getTrackedImages`, `findTrackedImage` from `./imageStore` (Task 1); `getTemplateInfo`, `applyEdit` from `./expressApi` (Task 2); `uploadImageToMeta` from `./metaUpload` (Task 3).
+- Produces: `actionListCampaignGraphics() -> Promise` (unchanged behavior, moved from app.js).
+- Produces: `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`.
+- Produces: `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise` — `sendImage` is injected `(to, link) => Promise` so tests don't need real WhatsApp calls.
+- Produces: `actionGenerateBulkGraphics(filename) -> Promise` (unchanged behavior, moved from app.js).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `actions.test.js`:
+
+```js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions');
+const { getTrackedImages } = require('./imageStore');
+
+test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => {
+ const reply = await actionCheckAllowedEdits('phone-1', 'img_1');
+ assert.match(reply, /Diwali Offer Banner/);
+ assert.match(reply, /discount_text/);
+});
+
+test('actionCheckAllowedEdits reports unknown images without throwing', async () => {
+ const reply = await actionCheckAllowedEdits('phone-2', 'img_nope');
+ assert.match(reply, /couldn't find that image/);
+});
+
+test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => {
+ let sendImageCalled = false;
+ const sendImage = async () => {
+ sendImageCalled = true;
+ };
+
+ const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage });
+
+ assert.match(reply, /can't edit background_color/);
+ assert.equal(sendImageCalled, false);
+});
+
+test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => {
+ const sentCalls = [];
+ const sendImage = async (to, link) => {
+ sentCalls.push({ to, link });
+ };
+
+ const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage });
+
+ assert.match(reply, /Updated "Summer Sale Flyer"/);
+ assert.equal(sentCalls.length, 1);
+ assert.equal(sentCalls[0].to, 'phone-4');
+ assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//);
+
+ const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2');
+ assert.equal(image.currentEdits.headline, 'Flash Sale');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node --test actions.test.js`
+Expected: FAIL — `Cannot find module './actions'`
+
+- [ ] **Step 3: Write the implementation**
+
+Create `actions.js`:
+
+```js
+const { getTrackedImages, findTrackedImage } = require('./imageStore');
+const { getTemplateInfo, applyEdit } = require('./expressApi');
+const { uploadImageToMeta } = require('./metaUpload');
+
+function formatUnknownImageMessage(phoneNumber) {
+ const images = getTrackedImages(phoneNumber);
+ const list = images.map((image) => `- ${image.name}`).join('\n');
+ return `I couldn't find that image. Here's what I have:\n${list}`;
+}
+
+async function actionListCampaignGraphics() {
+ // TODO: fetch from campaign API
+ return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster';
+}
+
+async function actionCheckAllowedEdits(phoneNumber, imageId) {
+ const image = findTrackedImage(phoneNumber, imageId);
+ if (!image) {
+ return formatUnknownImageMessage(phoneNumber);
+ }
+ const { unlockedLayers } = getTemplateInfo(image.templateId);
+ const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n');
+ return `Edits allowed on "${image.name}":\n${layerList}`;
+}
+
+async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) {
+ const image = findTrackedImage(phoneNumber, imageId);
+ if (!image) {
+ return formatUnknownImageMessage(phoneNumber);
+ }
+
+ const { unlockedLayers } = getTemplateInfo(image.templateId);
+ const requestedKeys = Object.keys(edits || {});
+ const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key));
+
+ if (disallowedKeys.length > 0) {
+ const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n');
+ return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`;
+ }
+
+ const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits);
+ image.currentEdits = mergedEdits;
+
+ const uploadedUrl = await uploadImageToMeta(renderedImageUrl);
+ await sendImage(phoneNumber, uploadedUrl);
+
+ const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n');
+ return `Updated "${image.name}":\n${summary}`;
+}
+
+async function actionGenerateBulkGraphics(filename) {
+ // TODO: parse CSV/Excel and call Adobe Express API per row
+ return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`;
+}
+
+module.exports = {
+ actionListCampaignGraphics,
+ actionCheckAllowedEdits,
+ actionEditGraphic,
+ actionGenerateBulkGraphics,
+};
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node --test actions.test.js`
+Expected: PASS — 4 tests passing
+
+- [ ] **Step 5: Run the full test suite**
+
+Run: `node --test`
+Expected: PASS — all tests across `imageStore.test.js`, `expressApi.test.js`, `metaUpload.test.js`, `actions.test.js` (15 tests total)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add actions.js actions.test.js
+git commit -m "feat: extract GPT action handlers and add image-edit validation flow"
+```
+
+---
+
+## Task 5: Wire `app.js` to the new modules
+
+**Files:**
+- Modify: `app.js:1-2` (imports)
+- Modify: `app.js:59-120` (tool definitions)
+- Modify: `app.js:122-143` (delete — action handlers now live in `actions.js`)
+- Modify: `app.js:147-174` (`decideAction` system prompt)
+- Modify: `app.js:218-245` (webhook switch statement)
+
+**Interfaces:**
+- Consumes: `getTrackedImages` from `./imageStore` (Task 1); `actionListCampaignGraphics`, `actionCheckAllowedEdits`, `actionEditGraphic`, `actionGenerateBulkGraphics` from `./actions` (Task 4).
+- Produces: nothing new — this task is pure wiring, verified manually (no unit test; this is the orchestration layer that touches the real WhatsApp/OpenAI SDKs already excluded from automated testing per the spec).
+
+- [ ] **Step 1: Update imports**
+
+At the top of `app.js`, replace:
+
+```js
+const express = require('express');
+const OpenAI = require('openai');
+```
+
+with:
+
+```js
+const express = require('express');
+const OpenAI = require('openai');
+const { getTrackedImages } = require('./imageStore');
+const {
+ actionListCampaignGraphics,
+ actionCheckAllowedEdits,
+ actionEditGraphic,
+ actionGenerateBulkGraphics,
+} = require('./actions');
+```
+
+- [ ] **Step 2: Add `image_id` to the `check_allowed_edits` and `edit_graphic` tool definitions**
+
+In the `tools` array, replace the `check_allowed_edits` entry:
+
+```js
+ {
+ type: 'function',
+ function: {
+ name: 'check_allowed_edits',
+ description: 'Check what edits are permitted on the current graphic',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+```
+
+with:
+
+```js
+ {
+ type: 'function',
+ function: {
+ name: 'check_allowed_edits',
+ description:
+ 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.',
+ parameters: {
+ type: 'object',
+ properties: {
+ image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' },
+ },
+ required: ['image_id'],
+ },
+ },
+ },
+```
+
+And replace the `edit_graphic` entry:
+
+```js
+ {
+ type: 'function',
+ function: {
+ name: 'edit_graphic',
+ description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)',
+ parameters: {
+ type: 'object',
+ properties: {
+ edits: {
+ type: 'object',
+ description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }',
+ },
+ },
+ required: ['edits'],
+ },
+ },
+ },
+```
+
+with:
+
+```js
+ {
+ type: 'function',
+ function: {
+ name: 'edit_graphic',
+ description:
+ 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.',
+ parameters: {
+ type: 'object',
+ properties: {
+ image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' },
+ edits: {
+ type: 'object',
+ description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }',
+ },
+ },
+ required: ['image_id', 'edits'],
+ },
+ },
+ },
+```
+
+- [ ] **Step 3: Delete the old inline action handlers**
+
+Delete this entire block (now provided by `actions.js`):
+
+```js
+// ── Action handlers (stubs — wire real APIs here) ────────────────────────────
+
+async function actionListCampaignGraphics() {
+ // TODO: fetch from campaign API
+ return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster';
+}
+
+async function actionCheckAllowedEdits() {
+ // TODO: fetch from Adobe Express API
+ return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color';
+}
+
+async function actionEditGraphic(edits) {
+ // TODO: call Adobe Express API
+ const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n');
+ return `Graphic updated successfully:\n${summary}`;
+}
+
+async function actionGenerateBulkGraphics(filename) {
+ // TODO: parse CSV/Excel and call Adobe Express API per row
+ return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`;
+}
+```
+
+- [ ] **Step 4: Add the tracked-images list to the `decideAction` system prompt**
+
+Replace:
+
+```js
+async function decideAction(phoneNumber, userMessage) {
+ const last3 = getHistory(phoneNumber).slice(-3);
+
+ const messages = [
+ {
+ role: 'system',
+ content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express.
+Analyze the user's message and conversation history, then call the appropriate tool.
+Always call exactly one tool — never reply with plain text.
+If the request is ambiguous or missing details, use ask_for_more_information.`,
+ },
+ ...last3,
+ { role: 'user', content: userMessage },
+ ];
+```
+
+with:
+
+```js
+async function decideAction(phoneNumber, userMessage) {
+ const last3 = getHistory(phoneNumber).slice(-3);
+ const trackedImages = getTrackedImages(phoneNumber);
+ const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n');
+
+ const messages = [
+ {
+ role: 'system',
+ content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express.
+Analyze the user's message and conversation history, then call the appropriate tool.
+Always call exactly one tool — never reply with plain text.
+If the request is ambiguous or missing details, use ask_for_more_information.
+
+Images previously sent to this user (reference by id):
+${imagesList}`,
+ },
+ ...last3,
+ { role: 'user', content: userMessage },
+ ];
+```
+
+- [ ] **Step 5: Pass `phoneNumber`/`image_id`/`sendImage` through the webhook switch**
+
+Replace:
+
+```js
+ case 'check_allowed_edits':
+ await sendText(phoneNumber, '⏳ Checking allowed edits...');
+ replyText = await actionCheckAllowedEdits();
+ break;
+
+ case 'edit_graphic':
+ await sendText(phoneNumber, '⏳ Applying edits to your graphic...');
+ replyText = await actionEditGraphic(args.edits);
+ break;
+```
+
+with:
+
+```js
+ case 'check_allowed_edits':
+ await sendText(phoneNumber, '⏳ Checking allowed edits...');
+ replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id);
+ break;
+
+ case 'edit_graphic':
+ await sendText(phoneNumber, '⏳ Applying edits to your graphic...');
+ replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage });
+ break;
+```
+
+- [ ] **Step 6: Verify the file is syntactically valid**
+
+Run: `node --check app.js`
+Expected: no output (silent success)
+
+- [ ] **Step 7: Verify the server still boots and the webhook-verification route still works**
+
+Run:
+
+```bash
+VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test node app.js &
+SERVER_PID=$!
+sleep 1
+curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123"
+kill $SERVER_PID
+```
+
+Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed.
+
+- [ ] **Step 8: Run the full test suite once more to confirm nothing broke**
+
+Run: `node --test`
+Expected: PASS — all 15 tests passing
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add app.js
+git commit -m "feat: wire image-edit flow into the webhook and GPT tool schema"
+```
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** template/layer lookup (Task 2), allowed-edit validation (Task 4 `actionEditGraphic`), Express edit + Meta upload + re-send (Task 4 + Task 5 wiring), image reference inferred by GPT from a system-prompt-provided list (Task 5 Step 4), pre-seeded mock images since real sending is out of scope (Task 1) — all covered.
+- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from the existing code (out of scope for this feature) and are intentional, not gaps in this plan.
+- **Type consistency:** `sendImage` signature `(to, link) => Promise` is consistent between `actions.js`'s `actionEditGraphic` (Task 4) and its call site in `app.js` (Task 5 Step 5), which passes the existing `sendImage(to, link)` from `app.js`. `image.id`/`image.name`/`image.templateId`/`image.currentEdits` field names are consistent across `imageStore.js`, `expressApi.js` consumers, and `actions.js`.
diff --git a/docs/superpowers/plans/2026-07-16-real-express-api-integration.md b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md
new file mode 100644
index 0000000000..4762840bb3
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md
@@ -0,0 +1,1039 @@
+# Real Adobe Express API Integration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace every mocked/hardcoded piece of the image-edit flow with real Adobe Express API calls (`tagged-documents`, `generate-variation`, `status`), driven by a shared `docId` catalog, ending with the real edited-image `thumbnailUrl` sent back over WhatsApp.
+
+**Architecture:** A new `expressAuth.js` handles IMS OAuth token fetch/cache. `expressApi.js` is rewritten to make real HTTP calls (tagged-document lookup, variation generation, status polling) plus small formatting/helper functions, using `expressAuth.js` for auth headers. `imageStore.js` is rewritten to read a shared JSON catalog (`data/express-templates.json`, `id`/`name`/`docId`) fresh on every call instead of hardcoded seed data, layering in-memory per-conversation edit state on top. `actions.js` is rewired to call the real `expressApi.js` functions (via a namespace import so tests can stub them directly) instead of the old mocks, with all Express API failures caught and turned into friendly WhatsApp replies. `app.js` gets a few extra labeled `console.log` lines for webhook fields that might carry docID metadata — no other behavior change. `metaUpload.js` is left completely untouched and unused.
+
+**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` + global `fetch` (no new dependencies).
+
+## Global Constraints
+
+- No new npm dependencies — use Node's built-in `node:test` test runner and global `fetch`, stubbed directly in tests (no mocking library).
+- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons.
+- Real Adobe Express HTTP errors must never reach the WhatsApp user raw — every call site catches and replaces them with a short friendly retry message, logging the technical detail (status/body/docId/jobId) server-side via `console.error`.
+- `data/express-templates.json` is committed to git (not gitignored) and is a flat, phone-number-agnostic catalog: `[{ id, name, docId }]`. No per-phone-number entries.
+- IMS token responses' `expires_in` is treated as **milliseconds** (Adobe IMS v3 convention), added directly to `Date.now()` — flagged as an assumption to verify against the real endpoint; isolated to one line in `expressAuth.js` if it needs to change to `* 1000`.
+- `metaUpload.js` and `metaUpload.test.js` stay in the repo, completely unmodified, and are not imported anywhere after this change.
+- A failed or timed-out edit must not be recorded into the per-conversation edit state — only a successful `generate-variation` + `status: succeeded` commits the edit, so retries don't compound bad state.
+
+---
+
+## Task 1: `expressAuth.js` — IMS token fetch/cache
+
+**Files:**
+- Create: `expressAuth.js`
+- Test: `expressAuth.test.js`
+
+**Interfaces:**
+- Consumes: env vars `EXPRESS_CLIENT_ID`, `EXPRESS_CLIENT_SECRET`, `EXPRESS_API_SCOPE` (optional), `EXPRESS_IMS_TOKEN_URL` (optional).
+- Produces: `getAccessToken() -> Promise` (cached IMS access token, refetches within 60s of expiry). `buildAuthHeaders() -> Promise<{ Authorization: string, 'X-API-KEY': string }>`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `expressAuth.test.js`:
+
+```js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const originalFetch = global.fetch;
+
+function freshExpressAuth() {
+ delete require.cache[require.resolve('./expressAuth')];
+ return require('./expressAuth');
+}
+
+test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => {
+ process.env.EXPRESS_CLIENT_ID = 'client-123';
+ process.env.EXPRESS_CLIENT_SECRET = 'secret-456';
+ global.fetch = async (url, options) => {
+ assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3');
+ assert.equal(options.method, 'POST');
+ assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded');
+ const body = options.body.toString();
+ assert.match(body, /grant_type=client_credentials/);
+ assert.match(body, /client_id=client-123/);
+ assert.match(body, /client_secret=secret-456/);
+ return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) };
+ };
+
+ const { getAccessToken } = freshExpressAuth();
+ const token = await getAccessToken();
+
+ assert.equal(token, 'tok-abc');
+ global.fetch = originalFetch;
+});
+
+test('getAccessToken caches the token and does not refetch on a second call', async () => {
+ process.env.EXPRESS_CLIENT_ID = 'client-123';
+ process.env.EXPRESS_CLIENT_SECRET = 'secret-456';
+ let fetchCalls = 0;
+ global.fetch = async () => {
+ fetchCalls += 1;
+ return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) };
+ };
+
+ const { getAccessToken } = freshExpressAuth();
+ const first = await getAccessToken();
+ const second = await getAccessToken();
+
+ assert.equal(first, 'tok-cached');
+ assert.equal(second, 'tok-cached');
+ assert.equal(fetchCalls, 1);
+ global.fetch = originalFetch;
+});
+
+test('getAccessToken refetches once the cached token is within 60s of expiring', async () => {
+ process.env.EXPRESS_CLIENT_ID = 'client-123';
+ process.env.EXPRESS_CLIENT_SECRET = 'secret-456';
+ let fetchCalls = 0;
+ global.fetch = async () => {
+ fetchCalls += 1;
+ return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) };
+ };
+
+ const { getAccessToken } = freshExpressAuth();
+ const first = await getAccessToken();
+ const second = await getAccessToken();
+
+ assert.equal(first, 'tok-1');
+ assert.equal(second, 'tok-2');
+ assert.equal(fetchCalls, 2);
+ global.fetch = originalFetch;
+});
+
+test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => {
+ process.env.EXPRESS_CLIENT_ID = 'client-123';
+ process.env.EXPRESS_CLIENT_SECRET = 'secret-456';
+ global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' });
+
+ const { getAccessToken } = freshExpressAuth();
+ await assert.rejects(() => getAccessToken(), /401/);
+ global.fetch = originalFetch;
+});
+
+test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => {
+ process.env.EXPRESS_CLIENT_ID = 'client-789';
+ process.env.EXPRESS_CLIENT_SECRET = 'secret-000';
+ global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) });
+
+ const { buildAuthHeaders } = freshExpressAuth();
+ const headers = await buildAuthHeaders();
+
+ assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' });
+ global.fetch = originalFetch;
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test expressAuth.test.js`
+Expected: FAIL — `Cannot find module './expressAuth'`
+
+- [ ] **Step 3: Write the implementation**
+
+Create `expressAuth.js`:
+
+```js
+const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3';
+const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext';
+const REFRESH_MARGIN_MS = 60_000;
+
+let cachedToken = null; // { accessToken, expiresAt }
+
+async function getAccessToken() {
+ if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) {
+ return cachedToken.accessToken;
+ }
+
+ const response = await fetch(IMS_TOKEN_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: process.env.EXPRESS_CLIENT_ID,
+ client_secret: process.env.EXPRESS_CLIENT_SECRET,
+ scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE,
+ }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`IMS token request failed ${response.status}: ${text}`);
+ }
+
+ const data = await response.json();
+ cachedToken = {
+ accessToken: data.access_token,
+ expiresAt: Date.now() + Number(data.expires_in),
+ };
+ return cachedToken.accessToken;
+}
+
+async function buildAuthHeaders() {
+ const token = await getAccessToken();
+ return {
+ Authorization: `Bearer ${token}`,
+ 'X-API-KEY': process.env.EXPRESS_CLIENT_ID,
+ };
+}
+
+module.exports = { getAccessToken, buildAuthHeaders };
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `node --test expressAuth.test.js`
+Expected: PASS — 5 tests passing
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add expressAuth.js expressAuth.test.js
+git commit -m "feat: add Adobe IMS token fetch/cache module"
+```
+
+---
+
+## Task 2: `imageStore.js` — real catalog-backed image store
+
+**Files:**
+- Create: `data/express-templates.json`
+- Modify: `imageStore.js` (full rewrite)
+- Modify: `imageStore.test.js` (full rewrite)
+
+**Interfaces:**
+- Consumes: env var `EXPRESS_TEMPLATES_FILE` (optional, defaults to `data/express-templates.json` relative to this file).
+- Produces: `getTrackedImages(phoneNumber) -> Array<{ id, name, docId, currentEdits }>`, `findTrackedImage(phoneNumber, imageId) -> object | undefined`, `recordEdits(phoneNumber, imageId, newEdits) -> object` (merges and returns the new `currentEdits`).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `imageStore.test.js`:
+
+```js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore');
+
+function writeFixtureCatalog(entries) {
+ const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
+ fs.writeFileSync(fixturePath, JSON.stringify(entries));
+ process.env.EXPRESS_TEMPLATES_FILE = fixturePath;
+}
+
+test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => {
+ writeFixtureCatalog([
+ { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' },
+ { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' },
+ ]);
+
+ const images = getTrackedImages('phone-1');
+
+ assert.equal(images.length, 2);
+ assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} });
+});
+
+test('getTrackedImages returns an empty list when the catalog file is missing', () => {
+ process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json');
+
+ const images = getTrackedImages('phone-2');
+
+ assert.deepEqual(images, []);
+});
+
+test('findTrackedImage returns the matching image by id', () => {
+ writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]);
+
+ const image = findTrackedImage('phone-3', 'img_3');
+
+ assert.equal(image.name, 'Croma Earbuds');
+});
+
+test('findTrackedImage returns undefined for an unknown id', () => {
+ writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]);
+
+ const image = findTrackedImage('phone-4', 'img_nope');
+
+ assert.equal(image, undefined);
+});
+
+test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => {
+ writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]);
+
+ recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' });
+ recordEdits('phone-5', 'img_1', { discount_text: '70%' });
+
+ const image = findTrackedImage('phone-5', 'img_1');
+ assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' });
+});
+
+test('recordEdits keeps edits independent per phone number', () => {
+ writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]);
+
+ recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' });
+
+ const otherPhoneImage = findTrackedImage('phone-7', 'img_1');
+ assert.deepEqual(otherPhoneImage.currentEdits, {});
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test imageStore.test.js`
+Expected: FAIL — assertions fail against the old hardcoded `SEED_IMAGES` behavior (module exists but returns the wrong shape/values)
+
+- [ ] **Step 3: Write the implementation**
+
+Replace the contents of `imageStore.js`:
+
+```js
+const fs = require('node:fs');
+const path = require('node:path');
+
+function catalogPath() {
+ return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, 'data', 'express-templates.json');
+}
+
+function loadCatalog() {
+ try {
+ const raw = fs.readFileSync(catalogPath(), 'utf8');
+ return JSON.parse(raw);
+ } catch (err) {
+ console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message });
+ return [];
+ }
+}
+
+const conversationEdits = new Map();
+
+function getTrackedImages(phoneNumber) {
+ return loadCatalog().map((entry) => ({
+ ...entry,
+ currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {},
+ }));
+}
+
+function findTrackedImage(phoneNumber, imageId) {
+ return getTrackedImages(phoneNumber).find((image) => image.id === imageId);
+}
+
+function recordEdits(phoneNumber, imageId, newEdits) {
+ const key = `${phoneNumber}:${imageId}`;
+ const merged = { ...(conversationEdits.get(key) || {}), ...newEdits };
+ conversationEdits.set(key, merged);
+ return merged;
+}
+
+module.exports = { getTrackedImages, findTrackedImage, recordEdits };
+```
+
+- [ ] **Step 4: Create the real catalog file**
+
+Create `data/express-templates.json` with the one confirmed real entry (the Diwali/Summer entries from the old mock had no real Express document behind them, so they're not carried forward as fake data — add more entries here once the UI app provides their real `docId`s):
+
+```json
+[
+ { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" }
+]
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `node --test imageStore.test.js`
+Expected: PASS — 6 tests passing
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add imageStore.js imageStore.test.js data/express-templates.json
+git commit -m "feat: read the shared docID catalog instead of hardcoded seed images"
+```
+
+---
+
+## Task 3: `expressApi.js` — real Adobe Express HTTP calls
+
+**Files:**
+- Modify: `expressApi.js` (full rewrite)
+- Modify: `expressApi.test.js` (full rewrite)
+
+**Interfaces:**
+- Consumes: `buildAuthHeaders` from `./expressAuth` (Task 1); env vars `EXPRESS_API_BASE_URL`, `EXPRESS_STATUS_POLL_INTERVAL_MS`, `EXPRESS_STATUS_POLL_TIMEOUT_MS` (all optional).
+- Produces: `getTaggedDocument(docId) -> Promise