[object Object]

← back to Marketing Command Center

engine: brain (candidates+llm+generate), scheduler (CAS tick+crash sweep), Engine panel UI

0b73831cc9456c3ceddb0e349d8950650337f738 · 2026-07-21 14:52:46 -0700 · Steve

Files touched

Diff

commit 0b73831cc9456c3ceddb0e349d8950650337f738
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 21 14:52:46 2026 -0700

    engine: brain (candidates+llm+generate), scheduler (CAS tick+crash sweep), Engine panel UI
---
 .gitignore                   |   2 +
 data/engine-queue.json       | 661 ++++++++++++++++++++++++++++++++++++++++++-
 modules/engine/candidates.js | 185 ++++++++++++
 modules/engine/generate.js   | 117 ++++++++
 modules/engine/index.js      |   8 +-
 modules/engine/llm.js        | 233 +++++++++++++++
 modules/engine/scheduler.js  | 177 ++++++++++++
 public/panels/engine.html    | 123 ++++++++
 public/panels/engine.js      | 450 +++++++++++++++++++++++++++++
 9 files changed, 1954 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 6999c27..b68d3ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,5 @@ data/sources/
 __pycache__/
 *.pyc
 data/cc-import-fm-clients.json
+data/engine-queue.json
+data/engine-config.json
diff --git a/data/engine-queue.json b/data/engine-queue.json
index 0637a08..24b2a83 100644
--- a/data/engine-queue.json
+++ b/data/engine-queue.json
@@ -1 +1,660 @@
-[]
\ No newline at end of file
+[
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-instagram",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:12.270Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "instagram",
+    "caption": "Subtle shimmer meets fluid movement in Onda, a pearl and silver wallcovering from Thibaut. Let light dance across its surface and transform your space.",
+    "hashtags": [
+      "#Thibaut",
+      "#Onda",
+      "#PearlAndSilver",
+      "#Wallcovering",
+      "#WatermarkCollection",
+      "#InteriorDesign",
+      "#LuxuryLiving"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "11:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-facebook",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:12.271Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "facebook",
+    "caption": "Onda from Thibaut brings a sense of motion and elegance to any room. Its pearl and silver tones catch the light in a way that feels both modern and timeless.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "09:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-bluesky",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:12.273Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "bluesky",
+    "caption": "Onda in pearl and silver from Thibaut — where light meets movement in a whisper of sheen.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "10:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-youtube",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:12.274Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "youtube",
+    "caption": "Onda Wallcovering in Pearl and Silver",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "12:00",
+    "scheduledAt": null,
+    "privacy": "public",
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": {
+      "description": "Experience the fluidity of Onda, a Thibaut wallcovering in pearl and silver. Its soft sheen and dynamic texture add depth and movement to any interior. Let the light guide your design story."
+    }
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-tiktok",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:12.275Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "tiktok",
+    "caption": "Watch light move across Onda’s pearl and silver surface — pure elegance in motion.",
+    "hashtags": [
+      "#Thibaut",
+      "#Onda",
+      "#Wallcovering",
+      "#InteriorDesign",
+      "#LuxuryLiving"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "18:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "gated",
+    "gateReason": "App Review pending — posts private-only (SELF_ONLY)",
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-threads",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:12.275Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "threads",
+    "caption": "Onda from Thibaut is more than a wallcovering — it's a play of light and texture. Its pearl and silver tones offer a quiet sophistication that evolves with the day’s changing light.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "12:30",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "gated",
+    "gateReason": "App Review pending — live publish requires Meta approval",
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-instagram",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:36.354Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "instagram",
+    "caption": "A gentle blend of cotton and linen, Bahama Cloth II in Medium Celadon brings soft texture and subtle depth to any interior. Perfect for adding warmth and character to a room.",
+    "hashtags": [
+      "#BahamaClothII",
+      "#PlainsDesign",
+      "#FabricElegance",
+      "#NeutralTones",
+      "#CottonLinenBlend",
+      "#InteriorDesign"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "11:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-facebook",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:36.357Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "facebook",
+    "caption": "Bahama Cloth II in Medium Celadon offers a tactile, organic feel with a refined sheen. Ideal for layering light and texture in your space.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "09:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-bluesky",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:36.358Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "bluesky",
+    "caption": "Soft, subtle, and rich in texture—Bahama Cloth II in Medium Celadon is a quiet luxury for walls that demand attention.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "10:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-youtube",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:36.359Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "youtube",
+    "caption": "Bahama Cloth II in Medium Celadon | Plains Fabric",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "12:00",
+    "scheduledAt": null,
+    "privacy": "public",
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": {
+      "description": "Explore the gentle sheen and soft hand of Bahama Cloth II in Medium Celadon. A textile that breathes and adds warmth to any room."
+    }
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-tiktok",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:36.360Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "tiktok",
+    "caption": "Soft textures, warm tones, and quiet luxury. #BahamaClothII",
+    "hashtags": [
+      "#PlainsDesign",
+      "#FabricLuxury",
+      "#InteriorTextures",
+      "#MediumCeladon",
+      "#Wallcovering"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "18:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "gated",
+    "gateReason": "App Review pending — posts private-only (SELF_ONLY)",
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-threads",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:50:36.361Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "threads",
+    "caption": "Bahama Cloth II in Medium Celadon from Plains is a textile that moves with light and offers a soft, tactile experience. Ideal for creating depth and warmth in any room.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "12:30",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "gated",
+    "gateReason": "App Review pending — live publish requires Meta approval",
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-instagram",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:51:03.670Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "instagram",
+    "caption": "Croci PU2909-2 from Tres Tintas brings a touch of elegance with its soft, textured finish and subtle movement in the light. A perfect addition to any room that demands sophistication.",
+    "hashtags": [
+      "#CrociPU2909-2",
+      "#TresTintas",
+      "#NonWovenWallcovering",
+      "#LuxuryDesign",
+      "#MadeInSpain",
+      "#Wallcovering"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "11:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-facebook",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:51:03.672Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "facebook",
+    "caption": "The Croci PU2909-2 wallcovering from Tres Tintas offers a refined texture and a depth of character that invites closer inspection. Its understated design makes it a choice for any interior space.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "09:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-bluesky",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:51:03.673Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "bluesky",
+    "caption": "Croci PU2909-2 from Tres Tintas—textured, subtle, and full of quiet sophistication.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "10:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-youtube",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:51:03.674Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "youtube",
+    "caption": "Croci PU2909-2 Wallcovering Close-Up",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "12:00",
+    "scheduledAt": null,
+    "privacy": "public",
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": {
+      "description": "Explore the refined texture and depth of the Croci PU2909-2 wallcovering from Tres Tintas. Its subtle movement in the light adds a layer of sophistication to any space."
+    }
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-tiktok",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:51:03.675Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "tiktok",
+    "caption": "Croci PU2909-2 from Tres Tintas—textured, refined, and full of quiet elegance.",
+    "hashtags": [
+      "#TresTintas",
+      "#Wallcovering",
+      "#NonWoven",
+      "#LuxuryDesign",
+      "#MadeInSpain"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "18:00",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "gated",
+    "gateReason": "App Review pending — posts private-only (SELF_ONLY)",
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-threads",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T21:51:03.676Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "threads",
+    "caption": "The Croci PU2909-2 wallcovering from Tres Tintas invites you to experience a blend of texture, light, and movement. It's a design that speaks softly but leaves a lasting impression.",
+    "hashtags": [],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "12:30",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "gated",
+    "gateReason": "App Review pending — live publish requires Meta approval",
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  }
+]
\ No newline at end of file
diff --git a/modules/engine/candidates.js b/modules/engine/candidates.js
new file mode 100644
index 0000000..7c0de42
--- /dev/null
+++ b/modules/engine/candidates.js
@@ -0,0 +1,185 @@
+// Engine candidates — pick the day's candidate products from the local dw_unified
+// Postgres mirror (READ-ONLY). Feeds generate.js. Everything here degrades
+// gracefully: a missing pg module, an unreachable DB, or an empty result all
+// resolve to [] rather than throwing, so the daily engine never crashes the MCC.
+//
+// Pipeline: SQL pull (recent ACTIVE, published, imaged products) → JS filters
+//   (a) leak-deny drop, (b) Kravet-family drop, (c) no-repeat-window drop,
+//   (d) variety pick (prefer unseen vendor + unseen product_type) → N products.
+const store = require('./store');
+const config = require('./config');
+const { isLeaky } = require('../../lib/leak-deny');
+const { isKravet } = require('../../lib/kravet.js');
+
+// ── Postgres (lazy; dw_unified is canonical on Kamatera, /tmp mirror locally) ──
+// Mirrors modules/calendars/lib.js: env-first, DATABASE_URL wins, else /tmp
+// socket to the dw_unified mirror. require('pg') is inside a try/catch so a host
+// without the pg module simply yields no candidates instead of crashing mount.
+let _pool = null;
+let _pgUnavailable = false;
+function pool() {
+  if (_pool) return _pool;
+  if (_pgUnavailable) return null;
+  let Pool;
+  try { ({ Pool } = require('pg')); }
+  catch (e) { console.error('[engine candidates] pg module missing —', e.message); _pgUnavailable = true; return null; }
+  try {
+    _pool = process.env.DATABASE_URL
+      ? new Pool({ connectionString: process.env.DATABASE_URL, max: 3 })
+      : new Pool({
+          host: process.env.PGHOST || '/tmp',
+          database: process.env.PGDATABASE || 'dw_unified',
+          user: process.env.PGUSER || process.env.USER || 'stevestudio2',
+          max: 3,
+        });
+    _pool.on('error', (e) => console.error('[engine candidates pool]', e.message)); // log, never crash the MCC
+    return _pool;
+  } catch (e) {
+    console.error('[engine candidates] pool init failed —', e.message);
+    _pgUnavailable = true;
+    return null;
+  }
+}
+
+// Base query — most-recent ACTIVE, online-store-published, imaged products within
+// a lookback window. The interval days are a sanitized integer interpolated into
+// the SQL (pg can't parameterize an interval literal); we hard-coerce to a small
+// positive integer so nothing user-derived reaches the query text.
+function buildSql(lookbackDays) {
+  const days = Math.min(3650, Math.max(1, parseInt(lookbackDays, 10) || 14));
+  return `
+    SELECT shopify_id, dw_sku, title, vendor, product_type, image_url, handle, tags,
+           created_at_shopify
+      FROM shopify_products
+     WHERE status = 'ACTIVE'
+       AND online_store_published = true
+       AND image_url IS NOT NULL
+       AND created_at_shopify >= now() - interval '${days} days'
+     ORDER BY created_at_shopify DESC
+     LIMIT 200`;
+}
+
+// Escalation ladder — config.lookbackDays is the PREFERENCE/floor. If the primary
+// window yields nothing (recent products simply haven't landed — the newest
+// published+imaged SKU can be weeks old), widen the window so the daily engine
+// never silently produces zero posts. DTD verdict (2026-07-21): resilient widen.
+function lookbackLadder(base) {
+  const b = Math.max(1, parseInt(base, 10) || 14);
+  const ladder = [b, 30, 60, 90, 180];
+  // de-dupe + keep strictly increasing from the base
+  return [...new Set(ladder.filter(d => d >= b))].sort((a, c) => a - c);
+}
+
+// Parse a PG text[] / CSV tags column into a clean string[] (same handling as
+// modules/calendars/lib.js parseTags).
+function parseTags(raw) {
+  if (!raw) return [];
+  if (Array.isArray(raw)) return raw.map(t => String(t).trim()).filter(Boolean);
+  let s = String(raw).trim();
+  if (s.startsWith('{') && s.endsWith('}')) s = s.slice(1, -1);
+  const out = []; let cur = '', inq = false;
+  for (const ch of s) {
+    if (ch === '"') { inq = !inq; continue; }
+    if (ch === ',' && !inq) { out.push(cur); cur = ''; continue; }
+    cur += ch;
+  }
+  if (cur) out.push(cur);
+  return out.map(t => t.trim()).filter(Boolean);
+}
+
+// (c) Build the set of product identifiers already queued within noRepeatDays so
+// we never re-feature the same product. Scans the existing queue's item.product.
+function recentlyFeaturedKeys(noRepeatDays) {
+  const days = Math.max(0, parseInt(noRepeatDays, 10) || 0);
+  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
+  const keys = new Set();
+  for (const it of store.load()) {
+    const t = new Date(it.createdAt || `${it.date}T00:00:00`).getTime();
+    if (!Number.isFinite(t) || t < cutoff) continue;
+    const p = it.product || {};
+    if (p.dwSku) keys.add(`sku:${String(p.dwSku).toLowerCase()}`);
+    if (p.shopifyId) keys.add(`sid:${String(p.shopifyId)}`);
+  }
+  return keys;
+}
+
+function seenKeyOf(row) {
+  const out = [];
+  if (row.dw_sku) out.push(`sku:${String(row.dw_sku).toLowerCase()}`);
+  if (row.shopify_id) out.push(`sid:${String(row.shopify_id)}`);
+  return out;
+}
+
+// (d) Variety pick — greedy: at each step prefer a row whose vendor AND
+// product_type are both still unseen, then unseen vendor, then unseen type, then
+// anything. Preserves the recency ORDER within each preference tier.
+function pickVariety(rows, n) {
+  const chosen = [];
+  const seenVendor = new Set();
+  const seenType = new Set();
+  const remaining = rows.slice();
+  const vendorOf = r => String(r.vendor || '').trim().toLowerCase();
+  const typeOf = r => String(r.product_type || '').trim().toLowerCase();
+
+  while (chosen.length < n && remaining.length) {
+    let idx = remaining.findIndex(r => !seenVendor.has(vendorOf(r)) && !seenType.has(typeOf(r)));
+    if (idx < 0) idx = remaining.findIndex(r => !seenVendor.has(vendorOf(r)));
+    if (idx < 0) idx = remaining.findIndex(r => !seenType.has(typeOf(r)));
+    if (idx < 0) idx = 0;
+    const [r] = remaining.splice(idx, 1);
+    seenVendor.add(vendorOf(r));
+    seenType.add(typeOf(r));
+    chosen.push(r);
+  }
+  return chosen;
+}
+
+// pickCandidates() — the day's products. Never throws; [] on any failure.
+async function pickCandidates() {
+  const cfg = config.getConfig();
+  const p = pool();
+  if (!p) return []; // pg module / DB unavailable — degrade gracefully
+
+  let rows = [];
+  try {
+    // Walk the lookback ladder — stop at the first window that yields rows that
+    // survive the leak + Kravet + no-repeat filters.
+    const noRepeat = recentlyFeaturedKeys(cfg.noRepeatDays);
+    for (const days of lookbackLadder(cfg.lookbackDays)) {
+      const res = await p.query(buildSql(days));
+      const filtered = res.rows.filter(r => {
+        // (a) leak-deny — never surface an upstream private-label name
+        if (isLeaky(r.vendor) || isLeaky(r.title)) return false;
+        // (b) Kravet-family — brand-controlled imagery is hard-blocked from reposting
+        if (isKravet(r.vendor, r.title)) return false;
+        // (c) no-repeat window — skip products already queued recently
+        if (seenKeyOf(r).some(k => noRepeat.has(k))) return false;
+        return true;
+      });
+      if (filtered.length) { rows = filtered; break; }
+    }
+  } catch (e) {
+    console.error('[engine candidates] query failed —', e.message);
+    return [];
+  }
+
+  if (!rows.length) return [];
+
+  // (d) variety pick down to productsPerDay
+  const n = Math.max(1, parseInt(cfg.productsPerDay, 10) || 3);
+  const picked = pickVariety(rows, n);
+
+  return picked.map(r => ({
+    shopifyId: r.shopify_id != null ? String(r.shopify_id) : '',
+    dwSku: r.dw_sku || '',
+    handle: r.handle || '',
+    title: r.title || '',
+    vendor: r.vendor || '',
+    productType: r.product_type || '',
+    imageUrl: r.image_url || '',
+    productUrl: `https://designerwallcoverings.com/products/${r.handle || ''}`,
+    tags: parseTags(r.tags),
+  }));
+}
+
+module.exports = { pickCandidates };
diff --git a/modules/engine/generate.js b/modules/engine/generate.js
new file mode 100644
index 0000000..a3dfb28
--- /dev/null
+++ b/modules/engine/generate.js
@@ -0,0 +1,117 @@
+// Engine generator — build today's suggestion queue. Idempotent: if today already
+// has items it returns them unchanged. Otherwise it picks candidate products,
+// writes per-channel captions, and upserts one queue item per product×channel.
+// Never throws on empty candidates (pg down / no rows) — returns [].
+const store = require('./store');
+const config = require('./config');
+const { pickCandidates } = require('./candidates');
+const { captionsFor } = require('./llm');
+
+const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads'];
+
+// local YYYY-MM-DD — must match modules/engine/index.js today() (local, not UTC).
+function today() {
+  const d = new Date();
+  const p = n => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
+}
+
+// compact yyyymmdd for the id prefix
+function ymd(dateStr) {
+  return String(dateStr).replace(/-/g, '');
+}
+
+// sanitize the sku/id segment of the id so it's a safe, stable token
+function idSegment(product) {
+  const raw = product.dwSku || product.shopifyId || 'unknown';
+  return String(raw).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
+}
+
+// build one queue item for a product×channel per the documented store.js shape
+function buildItem(product, channel, caps, dateStr) {
+  const cfg = config.getConfig();
+  const GATED = config.GATED_CHANNELS;
+  const slots = cfg.slots || {};
+  const suggestedSlot = (slots[channel] && slots[channel][0]) || null;
+
+  const ch = caps[channel] || {};
+  let caption, hashtags = [], description = null;
+
+  if (channel === 'youtube') {
+    // youtube: title goes in caption, description in its own field
+    caption = ch.title || '';
+    description = ch.description || null;
+  } else {
+    caption = ch.caption || '';
+    hashtags = Array.isArray(ch.hashtags) ? ch.hashtags : [];
+  }
+
+  return {
+    id: `eng-${ymd(dateStr)}-${idSegment(product)}-${channel}`,
+    date: dateStr,
+    createdAt: new Date().toISOString(),
+    product: {
+      shopifyId: product.shopifyId || '',
+      dwSku: product.dwSku || '',
+      handle: product.handle || '',
+      title: product.title || '',
+      vendor: product.vendor || '',
+      productType: product.productType || '',
+      imageUrl: product.imageUrl || '',
+      productUrl: product.productUrl || '',
+    },
+    channel,
+    caption,
+    hashtags,
+    mediaUrl: product.imageUrl || null,
+    videoUrl: null,
+    suggestedSlot,
+    scheduledAt: null,
+    privacy: channel === 'youtube' ? 'public' : null,
+    status: GATED[channel] ? 'gated' : 'suggested',
+    gateReason: GATED[channel] || null,
+    source: { llm: caps._source || 'template', cost: 0 },
+    attempts: 0,
+    lastError: null,
+    outboxId: null,
+    postedAt: null,
+    detail: description !== null ? { description } : null,
+  };
+}
+
+// generateToday() — idempotent daily generation. Returns today's items.
+async function generateToday() {
+  const dateStr = today();
+
+  // idempotent: today already generated → return as-is
+  const existing = store.load().filter(it => it.date === dateStr);
+  if (existing.length) return existing;
+
+  let products = [];
+  try {
+    products = await pickCandidates();
+  } catch (e) {
+    console.error('[engine generate] pickCandidates failed —', e.message);
+    products = [];
+  }
+  if (!products.length) return []; // pg down / empty — no throw
+
+  // captions are heavy (local LLM) — run sequentially, one product at a time
+  for (const product of products) {
+    let caps;
+    try {
+      caps = await captionsFor(product);
+    } catch (e) {
+      console.error('[engine generate] captionsFor failed —', e.message);
+      continue; // llm is never-blocking, but be defensive
+    }
+    for (const channel of CHANNELS) {
+      const item = buildItem(product, channel, caps, dateStr);
+      store.upsert(item); // id-idempotent
+    }
+  }
+
+  return store.load().filter(it => it.date === dateStr);
+}
+
+module.exports = { generateToday };
diff --git a/modules/engine/index.js b/modules/engine/index.js
index 5a0dc88..f132a92 100644
--- a/modules/engine/index.js
+++ b/modules/engine/index.js
@@ -55,9 +55,15 @@ module.exports = {
       const todays = items.filter(it => it.date === d);
       const counts = { suggested: 0, approved: 0, posting: 0, posted: 0, failed: 0, skipped: 0, gated: 0 };
       for (const it of todays) if (counts[it.status] != null) counts[it.status]++;
+      // Pull the real last-tick timestamp from the scheduler if it's built.
+      let lastTick = null;
+      try {
+        const scheduler = require('./scheduler');
+        if (scheduler && typeof scheduler.lastTick === 'function') lastTick = scheduler.lastTick();
+      } catch { /* scheduler not built yet — null */ }
       res.json({
         scheduler: process.env.ENGINE_SCHEDULER !== 'off',
-        lastTick: null, // scheduler agent will wire the real last-tick timestamp
+        lastTick,
         counts,
         total: items.length,
       });
diff --git a/modules/engine/llm.js b/modules/engine/llm.js
new file mode 100644
index 0000000..d6ce64f
--- /dev/null
+++ b/modules/engine/llm.js
@@ -0,0 +1,233 @@
+// Engine LLM — never-blocking local caption writer. Given a product, returns
+// per-channel captions in the DW brand voice. Tries the local Ollama primary
+// (Mac2), then the fallback (Mac1), then a deterministic template. EVERY caption
+// is leak-cleaned + trimmed to the channel limit regardless of source, so output
+// is always safe and non-empty.
+const { clean } = require('../../lib/leak-deny');
+const { CHANNEL_LIMITS } = require('./config');
+
+const PRIMARY = process.env.OLLAMA_PRIMARY || 'http://127.0.0.1:11434';
+const FALLBACK = process.env.OLLAMA_FALLBACK || 'http://192.168.1.133:11434';
+const MODEL = process.env.ENGINE_MODEL || 'qwen3:14b';
+const TIMEOUT_MS = 45000;
+
+const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads'];
+
+// ── DW brand-voice system prompt ─────────────────────────────────────────────
+const SYSTEM = `You are the social copywriter for Designer Wallcoverings, a luxury wallcoverings retailer.
+Voice rules (HARD):
+- Say "wallcovering" — never "wallpaper" — when speaking generically.
+- No generic superlatives: never "best", "amazing", "stunning", "gorgeous", "must-have".
+- Use sensory, material language: texture, light, movement, hand, depth, sheen.
+- NEVER mention price, cost, sale, or discount.
+Per-channel voice:
+- instagram: polished and editorial, 5-8 hashtags.
+- facebook: warm and conversational, a full sentence or two.
+- bluesky: witty and spare, <= 240 characters, no hashtags needed.
+- youtube: a video TITLE (<= 90 characters) plus a 2-sentence description.
+- tiktok: punchy and immediate, <= 120 characters, 3-5 hashtags.
+- threads: conversational and human, <= 400 characters.
+Return ONLY valid JSON, no prose, no code fences.`;
+
+function userPrompt(product) {
+  const p = {
+    title: product.title || '',
+    vendor: product.vendor || '',
+    productType: product.productType || '',
+    tags: Array.isArray(product.tags) ? product.tags.slice(0, 12) : [],
+  };
+  return `Write social captions for this Designer Wallcoverings product:
+${JSON.stringify(p, null, 2)}
+
+Output EXACTLY this JSON shape (fill every field, obey the per-channel voice + limits):
+{
+  "instagram": { "caption": "...", "hashtags": ["...", "..."] },
+  "facebook":  { "caption": "..." },
+  "bluesky":   { "caption": "..." },
+  "youtube":   { "title": "...", "description": "..." },
+  "tiktok":    { "caption": "...", "hashtags": ["...", "..."] },
+  "threads":   { "caption": "..." }
+}`;
+}
+
+// ── Ollama chat call ─────────────────────────────────────────────────────────
+async function ollamaChat(base, product) {
+  const ctrl = new AbortController();
+  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+  try {
+    const res = await fetch(`${base}/api/chat`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      signal: ctrl.signal,
+      body: JSON.stringify({
+        model: MODEL,
+        think: false,
+        stream: false,
+        format: 'json',
+        options: { temperature: 0.8, num_predict: 900 },
+        messages: [
+          { role: 'system', content: SYSTEM },
+          { role: 'user', content: userPrompt(product) },
+        ],
+      }),
+    });
+    if (!res.ok) throw new Error(`ollama ${base} HTTP ${res.status}`);
+    const data = await res.json();
+    const content = data && data.message && data.message.content;
+    if (!content) throw new Error(`ollama ${base} empty content`);
+    return parseModelJson(content);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+// Defensive JSON parse — strip code fences, pull the first {...} block.
+function parseModelJson(raw) {
+  let s = String(raw).trim();
+  s = s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
+  try { return JSON.parse(s); } catch {}
+  const start = s.indexOf('{');
+  const end = s.lastIndexOf('}');
+  if (start >= 0 && end > start) {
+    try { return JSON.parse(s.slice(start, end + 1)); } catch {}
+  }
+  throw new Error('unparseable model JSON');
+}
+
+// ── deterministic template fallback (never empty) ────────────────────────────
+function hashtagsFor(product) {
+  const tags = [];
+  const push = (t) => {
+    const h = '#' + String(t).replace(/[^A-Za-z0-9]+/g, '');
+    if (h.length > 1 && !tags.includes(h)) tags.push(h);
+  };
+  push('DesignerWallcoverings');
+  if (product.productType) push(product.productType.replace(/\s+/g, ''));
+  for (const t of (product.tags || [])) {
+    const val = t.includes(':') ? t.split(':').slice(1).join(':').trim() : t;
+    if (val) push(val);
+    if (tags.length >= 8) break;
+  }
+  push('InteriorDesign');
+  push('LuxuryInteriors');
+  return tags.slice(0, 8);
+}
+
+function templateCaptions(product) {
+  const title = product.title || 'This design';
+  const vendor = product.vendor || '';
+  const type = product.productType || 'wallcovering';
+  const by = vendor ? ` by ${vendor}` : '';
+  const tags = hashtagsFor(product);
+
+  return {
+    instagram: {
+      caption: `New arrival — ${title}${by}. ${type} with presence: texture, light, movement.`,
+      hashtags: tags,
+    },
+    facebook: {
+      caption: `Just in: ${title}${by}. A ${type} that plays with light and texture across the wall — worth a closer look.`,
+    },
+    bluesky: {
+      caption: `${title}${by} — a ${type} built on texture, depth, and the way light moves across it.`,
+    },
+    youtube: {
+      title: `${title}${by} — ${type}`.slice(0, 90),
+      description: `A closer look at ${title}${by}, a ${type} where texture and light do the work. See how it shifts across the wall.`,
+    },
+    tiktok: {
+      caption: `${title}${by} — texture you can feel.`,
+      hashtags: tags.slice(0, 5),
+    },
+    threads: {
+      caption: `New in: ${title}${by}. A ${type} that reads differently as the light moves — texture, depth, hand. What room would you put it in?`,
+    },
+  };
+}
+
+// ── post-processing: strip quotes, leak-clean, trim to channel limit ─────────
+function stripQuotes(s) {
+  return String(s == null ? '' : s).trim().replace(/^["'“”]+|["'“”]+$/g, '').trim();
+}
+
+// trim to `limit` chars at a word boundary (no mid-word cut); leak-cleaned first.
+function tidy(text, limit) {
+  let s = clean(stripQuotes(text));
+  if (!limit || s.length <= limit) return s;
+  let cut = s.slice(0, limit);
+  const lastSpace = cut.lastIndexOf(' ');
+  if (lastSpace > limit * 0.5) cut = cut.slice(0, lastSpace);
+  return cut.trim();
+}
+
+function tidyHashtags(list) {
+  if (!Array.isArray(list)) return [];
+  return list
+    .map(h => clean(stripQuotes(h)).replace(/\s+/g, ''))
+    .map(h => (h && !h.startsWith('#') ? '#' + h : h))
+    .filter(h => h && h.length > 1);
+}
+
+// Merge model output over the template so a missing channel/field is always
+// filled, then post-process EVERY field regardless of source.
+function finalize(modelOut, product) {
+  const tmpl = templateCaptions(product);
+  const m = modelOut && typeof modelOut === 'object' ? modelOut : {};
+  const pick = (ch, field) => {
+    const v = m[ch] && m[ch][field];
+    return (v != null && String(v).trim()) ? v : tmpl[ch][field];
+  };
+  const L = CHANNEL_LIMITS;
+
+  return {
+    instagram: {
+      caption: tidy(pick('instagram', 'caption'), L.instagram),
+      hashtags: tidyHashtags((m.instagram && m.instagram.hashtags) || tmpl.instagram.hashtags),
+    },
+    facebook: {
+      caption: tidy(pick('facebook', 'caption'), L.facebook),
+    },
+    bluesky: {
+      caption: tidy(pick('bluesky', 'caption'), L.bluesky),
+    },
+    youtube: {
+      title: tidy(pick('youtube', 'title'), L.youtube),
+      description: tidy(pick('youtube', 'description'), 5000),
+    },
+    tiktok: {
+      caption: tidy(pick('tiktok', 'caption'), L.tiktok),
+      hashtags: tidyHashtags((m.tiktok && m.tiktok.hashtags) || tmpl.tiktok.hashtags).slice(0, 5),
+    },
+    threads: {
+      caption: tidy(pick('threads', 'caption'), L.threads),
+    },
+  };
+}
+
+// captionsFor(product) — the public entry. Primary → fallback → template.
+// Always resolves; never rejects.
+async function captionsFor(product) {
+  let modelOut = null;
+  let source = 'template';
+
+  try {
+    modelOut = await ollamaChat(PRIMARY, product);
+    source = `${MODEL}@mac2`;
+  } catch (e1) {
+    console.error('[engine llm] primary failed —', e1.message);
+    try {
+      modelOut = await ollamaChat(FALLBACK, product);
+      source = `${MODEL}@mac1`;
+    } catch (e2) {
+      console.error('[engine llm] fallback failed —', e2.message);
+      modelOut = null;
+      source = 'template';
+    }
+  }
+
+  const out = finalize(modelOut, product);
+  out._source = source;
+  return out;
+}
+
+module.exports = { captionsFor };
diff --git a/modules/engine/scheduler.js b/modules/engine/scheduler.js
new file mode 100644
index 0000000..dddd8a6
--- /dev/null
+++ b/modules/engine/scheduler.js
@@ -0,0 +1,177 @@
+// Engine scheduler (agent 3). Fires APPROVED, due suggestions through the same
+// live rails as /publish (channels.postLive). Safe-by-construction:
+//   • ENGINE_SCHEDULER=off → never starts the interval (dev / test / staging).
+//   • Every state change goes through store.transition() — the compare-and-set
+//     that makes double-posting impossible (two ticks racing the same item: one
+//     wins the approved→posting claim, the loser's transition returns null).
+//   • Items are processed SEQUENTIALLY (never in parallel) to avoid platform
+//     rate spikes.
+//   • attempts is capped at 1: a failed post is NEVER auto-refired — Steve must
+//     re-approve. This is deliberate. A post that threw/failed may still have
+//     LANDED on the platform (the adapter succeeded but the response was lost),
+//     so silently retrying risks a duplicate live post.
+//   • CRASH SWEEP on start: any item stuck in 'posting' (the process died
+//     mid-post) is moved to 'failed' with a verify-first note — NEVER
+//     auto-refired, for the same land-before-crash reason.
+const store = require('./store');
+
+let _timer = null;
+let _started = false;
+let _ticking = false;
+let _lastTick = null;
+
+// ── logging ──────────────────────────────────────────────────────────────────
+// Matches the repo's bracketed-prefix console style ([mcc]/[engine]).
+const log = (...a) => console.log('[engine]', ...a);
+
+// summarize(detail) — best-effort one-line error from an adapter result array
+// when postLive returned no top-level `error` string (it doesn't on the happy
+// path, but a per-adapter failure lives inside detail[].error).
+function summarize(detail) {
+  try {
+    if (Array.isArray(detail)) {
+      const errs = detail.map(d => d && d.error).filter(Boolean);
+      if (errs.length) return errs.join('; ');
+    }
+    if (detail && typeof detail === 'object' && detail.error) return String(detail.error);
+  } catch { /* fall through */ }
+  return 'post failed';
+}
+
+// ── crash sweep ────────────────────────────────────────────────────────────────
+// Any item left in 'posting' means the process died between the approved→posting
+// claim and the posting→posted|failed resolve. The post MAY have landed, so we
+// move it to 'failed' (never auto-refire) and flag it for manual verification.
+function _sweep() {
+  let swept = 0;
+  for (const it of store.load()) {
+    if (it.status === 'posting') {
+      const done = store.transition(it.id, 'posting', 'failed', {
+        lastError: 'interrupted mid-post — verify on platform before re-approving',
+      });
+      if (done) { swept++; log('crash-sweep: posting →', it.id, 'moved to failed (verify before re-approve)'); }
+    }
+  }
+  if (swept) log(`crash-sweep swept ${swept} interrupted item(s)`);
+  return swept;
+}
+
+// ── content mapping ────────────────────────────────────────────────────────────
+// Build the channels.postLive `content` object from a queue item.
+// Caption = the caption text, then (only if hashtags exist) a blank line + the
+// hashtags joined by a single space.
+function buildContent(item) {
+  const tags = Array.isArray(item.hashtags) ? item.hashtags.filter(Boolean) : [];
+  const caption = tags.length
+    ? [item.caption, tags.join(' ')].filter(Boolean).join('\n\n')
+    : (item.caption || '');
+  return {
+    caption,
+    mediaUrl: item.mediaUrl,
+    videoUrl: item.videoUrl,
+    privacy: item.privacy || undefined,
+    title: item.channel === 'youtube' ? item.caption : undefined,
+    description: item.description || undefined,
+  };
+}
+
+// dueApproved() — items ready to fire this tick: approved, scheduled in the past,
+// and under the attempts cap (never re-fire a failed post).
+function dueApproved(items) {
+  const now = Date.now();
+  return items.filter(it =>
+    it.status === 'approved' &&
+    it.scheduledAt &&
+    Number.isFinite(Date.parse(it.scheduledAt)) &&
+    Date.parse(it.scheduledAt) <= now &&
+    (it.attempts || 0) < 1
+  );
+}
+
+// ── tick ────────────────────────────────────────────────────────────────────────
+async function _tickOnce() {
+  _lastTick = new Date().toISOString();
+  const items = store.load();
+  const due = dueApproved(items);
+  if (!due.length) return;
+
+  // require channels lazily + freshly so a test can monkey-patch postLive.
+  const channels = require('../channels');
+
+  for (const item of due) {
+    // (1) CLAIM: compare-and-set approved→posting. If null, another tick or a
+    //     manual action already took it — skip.
+    const claimed = store.transition(item.id, 'approved', 'posting', {
+      attempts: (item.attempts || 0) + 1,
+    });
+    if (!claimed) continue;
+
+    // (2) POST — wrapped so one item's failure never kills the tick.
+    try {
+      const content = buildContent(claimed);
+      const res = await channels.postLive(claimed.channel, content, {});
+      const ok = !!(res && res.ok);
+      store.transition(claimed.id, 'posting', ok ? 'posted' : 'failed', {
+        detail: (res && res.detail) || null,
+        outboxId: (res && res.outboxId) || null,
+        lastError: ok ? null : ((res && res.error) || summarize(res && res.detail)),
+        postedAt: ok ? new Date().toISOString() : null,
+      });
+      if (ok) log('posted', claimed.id, '→ outbox', (res && res.outboxId) || '(none)');
+      else log('FAILED', claimed.id + ':', (res && res.error) || summarize(res && res.detail));
+    } catch (err) {
+      const msg = (err && err.message) || String(err);
+      store.transition(claimed.id, 'posting', 'failed', { lastError: msg, postedAt: null });
+      log('FAILED', claimed.id + ':', msg);
+    }
+  }
+}
+
+// tick() — the interval body: re-entrancy guard so a slow tick never overlaps.
+async function tick() {
+  if (_ticking) return;
+  _ticking = true;
+  try {
+    await _tickOnce();
+  } catch (e) {
+    log('tick error:', (e && e.message) || String(e));
+  } finally {
+    _ticking = false;
+  }
+}
+
+// ── lifecycle ────────────────────────────────────────────────────────────────
+function start() {
+  if (process.env.ENGINE_SCHEDULER === 'off') {
+    log('scheduler disabled (ENGINE_SCHEDULER=off) — not starting');
+    return;
+  }
+  if (_started) return;
+  _started = true;
+
+  // Crash sweep BEFORE the interval so interrupted items are resolved up front.
+  _sweep();
+
+  // Steady 60s cadence, plus one prompt tick 5s after boot. Both unref()'d so
+  // they never keep the process alive on their own (mirrors social/index.js).
+  _timer = setInterval(() => { tick().catch(() => {}); }, 60 * 1000);
+  if (_timer.unref) _timer.unref();
+
+  const kick = setTimeout(() => { tick().catch(() => {}); }, 5 * 1000);
+  if (kick.unref) kick.unref();
+
+  log('scheduler started (60s cadence)');
+}
+
+function getState() {
+  return { started: _started, lastTick: _lastTick, ticking: _ticking };
+}
+
+module.exports = {
+  start,
+  getState,
+  lastTick: () => _lastTick,
+  // exported for tests only:
+  _tickOnce,
+  _sweep,
+};
diff --git a/public/panels/engine.html b/public/panels/engine.html
new file mode 100644
index 0000000..fd5838d
--- /dev/null
+++ b/public/panels/engine.html
@@ -0,0 +1,123 @@
+<!-- Engine panel — daily AI-suggested social posts, grouped by product, with a
+     per-channel approve → schedule → post flow. Markup only; engine.js drives it
+     and registers window.MCC_PANELS['engine'] = { init(root) }. -->
+
+<!-- 1 ── Header strip: scheduler dot + last tick · today's count chips · Generate + cost badge -->
+<div class="card" id="eng-header">
+  <div class="row" style="justify-content:space-between;align-items:center;row-gap:12px">
+    <div class="row" style="align-items:center;gap:16px;row-gap:8px">
+      <span class="eng-sched" id="eng-sched" title="scheduler status">
+        <span class="dot" id="eng-sched-dot"></span><span id="eng-sched-txt">scheduler…</span>
+      </span>
+      <span class="muted" id="eng-tick" style="font-size:12px"></span>
+      <span class="eng-chips" id="eng-chips"></span>
+    </div>
+    <div class="row" style="align-items:center;gap:10px">
+      <span class="pill eng-cost" id="eng-cost" title="captions generated by local qwen3:14b — no API cost">$0 local</span>
+      <button class="btn gold" id="eng-generate">⚡ Generate today</button>
+    </div>
+  </div>
+  <div id="eng-banner" class="eng-banner" hidden></div>
+</div>
+
+<!-- 2 ── Today's Suggestions (grouped by product) -->
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline">
+    <h2 style="margin:0">Today's Suggestions</h2>
+    <span class="muted" id="eng-today-count" style="font-size:12.5px"></span>
+  </div>
+  <div id="eng-today" style="margin-top:14px">
+    <div class="loading">Loading today's suggestions…</div>
+  </div>
+</div>
+
+<!-- 3 ── Pipeline strip: last 7 days from /queue, horizontal scroll -->
+<div class="card">
+  <div class="row" style="justify-content:space-between;align-items:baseline">
+    <h2 style="margin:0">Pipeline · last 7 days</h2>
+    <span class="muted" id="eng-pipe-count" style="font-size:12.5px"></span>
+  </div>
+  <div id="eng-pipeline" class="eng-pipe" style="margin-top:12px">
+    <div class="muted">Loading history…</div>
+  </div>
+</div>
+
+<style>
+  /* header */
+  #eng-header .eng-sched{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--ink)}
+  #eng-header .eng-sched .dot::before{margin-right:6px}
+  .eng-chips{display:inline-flex;gap:6px;flex-wrap:wrap}
+  .eng-chips .pill{background:var(--cream);color:var(--accent)}
+  .eng-chips .pill.on{background:#e3efe0;color:#3a6b3a}
+  .eng-chips .pill.warn{background:#f3e9e9;color:#9a5a5a}
+  .eng-chips .pill.blue{background:#eef3f8;color:#3a5a8a}
+  .eng-cost{background:#eef3f8;color:#3a5a8a;cursor:default}
+  .eng-cost.template{background:#f6f0e4;color:#7a5a2a}
+  .eng-banner{margin-top:12px;background:#f3e9e9;border:1px solid #e2c9c9;color:#8a4a4a;border-radius:10px;padding:9px 13px;font-size:12.5px}
+  .eng-banner.ok{background:#e9f3e9;border-color:#c9e2c9;color:#3a6b3a}
+
+  /* product cards */
+  .eng-prod{display:flex;gap:16px;border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;background:var(--paper)}
+  .eng-prod .thumb{flex:none;width:104px;height:104px;border-radius:9px;overflow:hidden;background:var(--cream);border:1px solid var(--line)}
+  .eng-prod .thumb a{display:block;width:100%;height:100%}
+  .eng-prod .thumb img{width:100%;height:100%;object-fit:cover;display:block}
+  .eng-prod .thumb .ph{width:100%;height:100%;display:grid;place-items:center;color:var(--mut);font-size:26px}
+  .eng-prod .body{flex:1;min-width:0}
+  .eng-prod .ptitle{font:600 15px/1.25 "Cormorant Garamond",Georgia,serif;margin:0}
+  .eng-prod .ptitle a{color:var(--ink);text-decoration:none}
+  .eng-prod .ptitle a:hover{text-decoration:underline}
+  .eng-prod .pmeta{font-size:11.5px;color:var(--mut);margin:2px 0 3px}
+  .eng-prod .pwhen{font-size:10.5px;color:var(--mut)}
+
+  /* channel tabs */
+  .eng-tabs{display:flex;gap:5px;flex-wrap:wrap;margin:10px 0 8px}
+  .eng-tab{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:8px;
+    padding:4px 9px;font-size:12px;font-weight:600;color:var(--ink);cursor:pointer;line-height:1.3}
+  .eng-tab:hover{background:var(--cream)}
+  .eng-tab.active{border-color:var(--ink);box-shadow:0 0 0 1px var(--ink) inset}
+  .eng-tab .ic{font-size:13px}
+  .eng-tab .st{font-size:9px;text-transform:uppercase;letter-spacing:.4px}
+  /* status coloring */
+  .eng-tab.s-approved{background:#eef3f8;border-color:#c3d4e8;color:#2f4f7a}
+  .eng-tab.s-posted{background:#e9f3e9;border-color:#c9e2c9;color:#3a6b3a}
+  .eng-tab.s-failed{background:#f6ecec;border-color:#e2c9c9;color:#9a4a4a}
+  .eng-tab.s-skipped{color:var(--mut);text-decoration:line-through;opacity:.7}
+  .eng-tab.s-gated{opacity:.5}
+  .eng-tab.s-posting{animation:eng-pulse 1s ease-in-out infinite}
+  @keyframes eng-pulse{0%,100%{opacity:1}50%{opacity:.45}}
+
+  /* active channel detail */
+  .eng-detail{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff}
+  .eng-detail .cap{font-size:13px;line-height:1.5;white-space:pre-wrap;color:var(--ink)}
+  .eng-detail .cap.muted{color:var(--mut)}
+  .eng-detail textarea{margin-top:2px;min-height:88px}
+  .eng-detail .tags{margin:8px 0 0;display:flex;gap:4px;flex-wrap:wrap}
+  .eng-detail .slot{font-size:11.5px;color:var(--mut);margin:8px 0 6px}
+  .eng-detail .slot b{color:var(--ink)}
+  .eng-detail .gate{background:#f6f0e4;border:1px solid #e6d6b8;color:#7a5a2a;border-radius:8px;padding:7px 10px;font-size:12px;margin-bottom:8px}
+  .eng-actions{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:6px}
+  .eng-actions .btn{padding:6px 12px;font-size:12.5px}
+  .eng-actions .msg{font-size:11.5px;color:var(--mut)}
+  .eng-sched-inline{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px;padding:8px;background:var(--cream);border-radius:8px}
+  .eng-sched-inline input,.eng-sched-inline select{width:auto;font-size:12px;padding:6px 8px}
+  .eng-sched-inline label{margin:0;font-size:11px}
+
+  /* pipeline strip */
+  .eng-pipe{display:flex;gap:10px;overflow-x:auto;padding-bottom:6px}
+  .eng-pipe::-webkit-scrollbar{height:8px}
+  .eng-pipe::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px}
+  .eng-pc{flex:none;width:150px;border:1px solid var(--line);border-radius:10px;background:#fff;overflow:hidden}
+  .eng-pc .pt{width:100%;height:82px;object-fit:cover;display:block;background:var(--cream)}
+  .eng-pc .pt.ph{display:grid;place-items:center;color:var(--mut);font-size:22px}
+  .eng-pc .meta{padding:7px 9px}
+  .eng-pc .meta .ch{font-size:12px;font-weight:600}
+  .eng-pc .meta .st{font-size:10px;margin-top:2px}
+  .eng-pc .meta .st.s-posted{color:#3a6b3a}
+  .eng-pc .meta .st.s-failed{color:#9a4a4a}
+  .eng-pc .meta .st.s-approved{color:#2f4f7a}
+  .eng-pc .meta .st.s-skipped{color:var(--mut);text-decoration:line-through}
+  .eng-pc .meta .tm{font-size:10px;color:var(--mut);margin-top:1px}
+
+  .eng-empty{color:var(--mut);text-align:center;padding:26px;font-size:13px}
+  .eng-empty .btn{margin-top:12px}
+</style>
diff --git a/public/panels/engine.js b/public/panels/engine.js
new file mode 100644
index 0000000..099550c
--- /dev/null
+++ b/public/panels/engine.js
@@ -0,0 +1,450 @@
+// Engine panel — daily AI-suggested social posts grouped BY PRODUCT, with a
+// per-channel approve → schedule → post flow, a header status strip, and a
+// last-7-days pipeline strip. Registers into the shell's MCC_PANELS map (app.js).
+// Dependency-free vanilla JS; every fetch is wrapped and API errors render an
+// inline banner (never a blank panel). Auto-refreshes /status + /today (cheap,
+// non-generating) every 30s and pauses when the tab is hidden.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['engine'] = {
+  init(root) {
+    const ORIGIN = location.origin;
+    const $ = s => root.querySelector(s);
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+
+    const CHAN = {
+      facebook:  { icon: '📘', label: 'Facebook' },
+      instagram: { icon: '📷', label: 'Instagram' },
+      tiktok:    { icon: '🎵', label: 'TikTok' },
+      youtube:   { icon: '▶️', label: 'YouTube' },
+      bluesky:   { icon: '🦋', label: 'Bluesky' },
+      threads:   { icon: '🧵', label: 'Threads' },
+    };
+    const chanIcon  = c => (CHAN[c] && CHAN[c].icon) || '•';
+    const chanLabel = c => (CHAN[c] && CHAN[c].label) || c;
+
+    // Local-day + local-time formatting (admin's timezone).
+    const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
+    const fmtTime = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); };
+    const pad = n => String(n).padStart(2, '0');
+    function todayISO() { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
+    // datetime-local value (no seconds, no offset) for a given day + HH:MM.
+    function slotLocalValue(dayISO, hhmm) {
+      const [h, m] = String(hhmm || '09:00').split(':');
+      return `${dayISO}T${pad(+h || 9)}:${pad(+m || 0)}`;
+    }
+    // Convert a datetime-local string back to a full ISO w/ the browser offset.
+    function localToISO(v) { if (!v) return null; const d = new Date(v); return isNaN(d) ? null : d.toISOString(); }
+
+    // ── wrapped fetch helpers ─────────────────────────────────────────────────
+    async function jget(url) {
+      const r = await fetch(ORIGIN + url, { credentials: 'same-origin' });
+      if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
+      return r.json();
+    }
+    async function jpost(url, body) {
+      const r = await fetch(ORIGIN + url, {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        credentials: 'same-origin', body: JSON.stringify(body || {}),
+      });
+      let j = {}; try { j = await r.json(); } catch {}
+      if (!r.ok && !j.error) j.error = `HTTP ${r.status}`;
+      return j;
+    }
+
+    function banner(msg, ok) {
+      const el = $('#eng-banner'); if (!el) return;
+      if (!msg) { el.hidden = true; el.textContent = ''; return; }
+      el.hidden = false; el.textContent = msg;
+      el.className = 'eng-banner' + (ok ? ' ok' : '');
+    }
+
+    // ── state ─────────────────────────────────────────────────────────────────
+    const state = {
+      status: null,
+      items: [],            // today's items
+      active: {},           // productKey → active channel id
+      editing: {},          // itemId → true while its textarea is open
+      generating: false,
+    };
+    const productKey = it => (it.product && (it.product.dwSku || it.product.shopifyId || it.product.handle || it.product.title)) || it.id;
+
+    // ── 1 · header ────────────────────────────────────────────────────────────
+    function renderHeader() {
+      const s = state.status || {};
+      const on = !!s.scheduler;
+      const dot = $('#eng-sched-dot'); if (dot) dot.className = 'dot ' + (on ? 'ok' : 'bad');
+      const txt = $('#eng-sched-txt'); if (txt) txt.textContent = on ? 'Scheduler on' : 'Scheduler off';
+      const tick = $('#eng-tick');
+      if (tick) tick.textContent = s.lastTick ? `last tick ${fmtTime(s.lastTick)}` : (on ? 'no tick yet' : '');
+      const c = s.counts || {};
+      const chips = [
+        { k: 'suggested', label: 'Suggested', n: c.suggested || 0, cls: '' },
+        { k: 'approved',  label: 'Scheduled', n: c.approved || 0,  cls: 'blue' },
+        { k: 'posted',    label: 'Posted',    n: c.posted || 0,    cls: 'on' },
+        { k: 'failed',    label: 'Failed',    n: c.failed || 0,    cls: (c.failed ? 'warn' : '') },
+      ];
+      if (c.gated) chips.push({ k: 'gated', label: 'Gated', n: c.gated, cls: 'warn' });
+      const chipsEl = $('#eng-chips');
+      if (chipsEl) chipsEl.innerHTML = chips.map(ch =>
+        `<span class="pill ${ch.cls}" title="${esc(ch.label)} today">${esc(ch.label)} ${ch.n}</span>`).join('');
+
+      // Cost badge — template if any visible item was written by the template engine.
+      const cost = $('#eng-cost');
+      if (cost) {
+        const anyTemplate = state.items.some(it => it.source && it.source.llm === 'template');
+        if (anyTemplate) {
+          cost.className = 'pill eng-cost template';
+          cost.textContent = '$0 template';
+          cost.title = 'captions written by the built-in template engine — no API cost';
+        } else {
+          cost.className = 'pill eng-cost';
+          cost.textContent = '$0 local';
+          cost.title = 'captions generated by local qwen3:14b — no API cost';
+        }
+      }
+    }
+
+    // ── 2 · today's suggestions (grouped by product) ──────────────────────────
+    function renderToday() {
+      const box = $('#eng-today');
+      const cnt = $('#eng-today-count');
+      if (!box) return;
+
+      if (state.generating) {
+        box.innerHTML = `<div class="eng-empty">
+          <div class="loading" style="padding:8px">Generating today's suggestions ($0 local model)…</div>
+          <div class="muted" style="font-size:12px">The local qwen3:14b caption pass can take 1–3 minutes on a cold start. This won't cost anything.</div>
+        </div>`;
+        if (cnt) cnt.textContent = '';
+        return;
+      }
+
+      const items = state.items.slice();
+      if (!items.length) {
+        box.innerHTML = `<div class="eng-empty">
+          No suggestions yet for today.
+          <div><button class="btn gold" id="eng-empty-gen">⚡ Generate today's suggestions</button></div>
+        </div>`;
+        const b = $('#eng-empty-gen'); if (b) b.onclick = doGenerate;
+        if (cnt) cnt.textContent = '';
+        return;
+      }
+
+      // group by product, preserving first-seen order.
+      const groups = new Map();
+      for (const it of items) {
+        const k = productKey(it);
+        if (!groups.has(k)) groups.set(k, []);
+        groups.get(k).push(it);
+      }
+      if (cnt) cnt.textContent = `${groups.size} product${groups.size === 1 ? '' : 's'} · ${items.length} suggestion${items.length === 1 ? '' : 's'}`;
+
+      box.innerHTML = [...groups.entries()].map(([k, its]) => productCardHTML(k, its)).join('');
+
+      // wire each product card
+      for (const [k, its] of groups.entries()) wireProductCard(box, k, its);
+    }
+
+    function productCardHTML(key, its) {
+      const p = (its[0] && its[0].product) || {};
+      const img = p.imageUrl
+        ? `<a href="${esc(p.productUrl || '#')}" target="_blank" rel="noopener noreferrer"><img src="${esc(p.imageUrl)}" alt="${esc(p.title || '')}" loading="lazy"></a>`
+        : `<div class="ph">🖼️</div>`;
+      const created = its.map(i => i.createdAt).filter(Boolean).sort()[0];
+      const meta = [p.vendor, p.productType].filter(Boolean).map(esc).join(' · ');
+      const titleInner = p.productUrl
+        ? `<a href="${esc(p.productUrl)}" target="_blank" rel="noopener noreferrer">${esc(p.title || 'Untitled product')}</a>`
+        : esc(p.title || 'Untitled product');
+
+      // active channel default = first item's channel unless already chosen
+      if (!state.active[key]) state.active[key] = its[0].channel;
+      const active = state.active[key];
+
+      const tabs = its.map(it => {
+        const st = it.status || 'suggested';
+        const activeCls = it.channel === active ? ' active' : '';
+        const stLabel = st === 'approved' ? (it.scheduledAt ? fmtTime(it.scheduledAt) : 'sched')
+          : st === 'posted' ? '✓'
+          : st === 'failed' ? '✗'
+          : st === 'posting' ? '…'
+          : st === 'gated' ? '⛔'
+          : st === 'skipped' ? '—' : '';
+        const title = st === 'failed' && it.lastError ? ` title="${esc(it.lastError)}"` : (st === 'approved' && it.scheduledAt ? ` title="scheduled ${esc(fmtWhen(it.scheduledAt))}"` : '');
+        return `<button class="eng-tab s-${esc(st)}${activeCls}" data-chan="${esc(it.channel)}"${title}>
+          <span class="ic">${chanIcon(it.channel)}</span>${chanLabel(it.channel)}
+          ${stLabel ? `<span class="st">${esc(stLabel)}</span>` : ''}
+        </button>`;
+      }).join('');
+
+      return `<div class="eng-prod" data-key="${esc(key)}">
+        <div class="thumb">${img}</div>
+        <div class="body">
+          <div class="ptitle">${titleInner}</div>
+          <div class="pmeta">${meta || '&nbsp;'}${p.dwSku ? ` · <span class="muted">${esc(p.dwSku)}</span>` : ''}</div>
+          <div class="pwhen" title="${esc(created || '')}">🕓 ${esc(fmtWhen(created))}</div>
+          <div class="eng-tabs">${tabs}</div>
+          <div class="eng-detail" data-detail></div>
+        </div>
+      </div>`;
+    }
+
+    function wireProductCard(box, key, its) {
+      const card = box.querySelector(`.eng-prod[data-key="${cssEsc(key)}"]`);
+      if (!card) return;
+      const byChan = Object.fromEntries(its.map(it => [it.channel, it]));
+      card.querySelectorAll('.eng-tab').forEach(tab => {
+        tab.onclick = () => {
+          state.active[key] = tab.dataset.chan;
+          card.querySelectorAll('.eng-tab').forEach(t => t.classList.toggle('active', t === tab));
+          renderDetail(card, byChan[tab.dataset.chan]);
+        };
+      });
+      renderDetail(card, byChan[state.active[key]]);
+    }
+
+    // escape a value for use in a [data-key="…"] attribute selector
+    function cssEsc(v) {
+      if (window.CSS && CSS.escape) return CSS.escape(String(v));
+      return String(v).replace(/["\\\]]/g, '\\$&');
+    }
+
+    function renderDetail(card, it) {
+      const box = card.querySelector('[data-detail]');
+      if (!box || !it) { if (box) box.innerHTML = ''; return; }
+      const st = it.status || 'suggested';
+      const editing = !!state.editing[it.id];
+
+      // gated → gate reason + Arm
+      if (st === 'gated') {
+        box.innerHTML = `
+          <div class="gate">⛔ Held: ${esc(it.gateReason || 'channel not available for live posting yet')}</div>
+          <div class="cap ${it.caption ? '' : 'muted'}">${esc(it.caption || '(caption suppressed until armed)')}</div>
+          <div class="eng-actions"><button class="btn ghost" data-act="arm">Arm →</button><span class="msg"></span></div>`;
+        wireActions(box, card, it);
+        return;
+      }
+
+      const tags = (it.hashtags || []).slice(0, 12).map(t => `<span class="pill">${esc(t.replace(/^#*/, '#'))}</span>`).join('');
+      const capBlock = editing
+        ? `<textarea data-cap>${esc(it.caption || '')}</textarea>`
+        : `<div class="cap ${it.caption ? '' : 'muted'}">${esc(it.caption || '(no caption)')}</div>`;
+
+      // scheduled/posted read-outs
+      let statusLine = '';
+      if (st === 'approved' && it.scheduledAt) statusLine = `<div class="slot">📅 Scheduled for <b>${esc(fmtWhen(it.scheduledAt))}</b></div>`;
+      else if (st === 'posted') statusLine = `<div class="slot">✅ Posted ${esc(fmtWhen(it.postedAt))}${it.outboxId ? ` · <span class="muted">outbox ${esc(it.outboxId)}</span>` : ''}</div>`;
+      else if (st === 'failed') statusLine = `<div class="slot" style="color:#9a4a4a">✗ Failed${it.lastError ? ` — ${esc(it.lastError)}` : ''}</div>`;
+      else if (st === 'skipped') statusLine = `<div class="slot muted">Skipped</div>`;
+      else statusLine = `<div class="slot">Suggested slot <b>${esc(it.suggestedSlot || '—')}</b></div>`;
+
+      // actions available while suggested (and edit while suggested|approved)
+      let actions = '';
+      if (st === 'suggested') {
+        actions = `
+          <button class="btn gold" data-act="approve">Approve ▸ time</button>
+          <button class="btn ghost" data-act="edit">${editing ? 'Save' : 'Edit'}</button>
+          <button class="btn ghost" data-act="skip">Skip</button>
+          <span class="msg"></span>`;
+      } else if (st === 'approved') {
+        actions = `<button class="btn ghost" data-act="edit">${editing ? 'Save' : 'Edit'}</button><span class="msg"></span>`;
+      }
+
+      box.innerHTML = `
+        ${capBlock}
+        <div class="tags">${tags}</div>
+        ${statusLine}
+        <div class="eng-actions">${actions}</div>
+        <div class="eng-sched-inline" data-picker hidden>
+          <label>When</label>
+          <input type="datetime-local" data-when value="${esc(slotLocalValue(it.date || todayISO(), it.suggestedSlot))}">
+          ${it.channel === 'youtube' ? `<label>Privacy</label>
+            <select data-privacy>
+              <option value="public">public</option>
+              <option value="unlisted">unlisted</option>
+              <option value="private">private</option>
+            </select>` : ''}
+          <button class="btn gold" data-act="approve-confirm">Confirm</button>
+          <button class="btn ghost" data-act="approve-cancel">Cancel</button>
+        </div>`;
+      wireActions(box, card, it);
+    }
+
+    function wireActions(box, card, it) {
+      const msg = box.querySelector('.msg');
+      const setMsg = t => { if (msg) msg.textContent = t; };
+      box.querySelectorAll('[data-act]').forEach(b => {
+        b.onclick = () => handleAction(b.dataset.act, it, box, card, setMsg);
+      });
+    }
+
+    async function handleAction(act, it, box, card, setMsg) {
+      if (act === 'edit') {
+        if (state.editing[it.id]) {
+          // save
+          const ta = box.querySelector('[data-cap]');
+          const caption = ta ? ta.value : it.caption;
+          setMsg('saving…');
+          const r = await jpost('/api/engine/edit', { id: it.id, caption });
+          if (r.error) { setMsg('✗ ' + r.error); return; }
+          state.editing[it.id] = false;
+          applyUpdated(r.item);
+        } else {
+          state.editing[it.id] = true;
+          renderDetail(card, it);
+        }
+        return;
+      }
+      if (act === 'skip') {
+        if (!confirm('Skip this suggestion? It won\'t be posted.')) return;
+        setMsg('skipping…');
+        const r = await jpost('/api/engine/skip', { id: it.id });
+        if (r.error) { setMsg('✗ ' + r.error); return; }
+        applyUpdated(r.item); refreshStatus();
+        return;
+      }
+      if (act === 'arm') {
+        setMsg('arming…');
+        const r = await jpost('/api/engine/arm', { id: it.id });
+        if (r.error) { setMsg('✗ ' + r.error); return; }
+        applyUpdated(r.item); refreshStatus();
+        return;
+      }
+      if (act === 'approve') {
+        // open the inline picker
+        const picker = box.querySelector('[data-picker]');
+        if (picker) picker.hidden = false;
+        return;
+      }
+      if (act === 'approve-cancel') {
+        const picker = box.querySelector('[data-picker]');
+        if (picker) picker.hidden = true;
+        return;
+      }
+      if (act === 'approve-confirm') {
+        const whenEl = box.querySelector('[data-when]');
+        const privEl = box.querySelector('[data-privacy]');
+        const scheduledAt = whenEl ? localToISO(whenEl.value) : null;
+        const body = { id: it.id };
+        if (scheduledAt) body.scheduledAt = scheduledAt;
+        if (privEl) body.privacy = privEl.value;
+        setMsg('approving…');
+        const r = await jpost('/api/engine/approve', body);
+        if (r.error) { setMsg('✗ ' + r.error); return; }
+        applyUpdated(r.item); refreshStatus();
+        return;
+      }
+    }
+
+    // Merge one updated item back into state.items and re-render just the affected
+    // product card (cheap; keeps the active-tab selection).
+    function applyUpdated(item) {
+      if (!item) return;
+      const i = state.items.findIndex(x => x.id === item.id);
+      if (i >= 0) state.items[i] = { ...state.items[i], ...item };
+      else state.items.push(item);
+      renderToday();
+    }
+
+    // ── 3 · pipeline strip (last 7 days) ──────────────────────────────────────
+    async function loadPipeline() {
+      const box = $('#eng-pipeline'); const cnt = $('#eng-pipe-count');
+      try {
+        const d = await jget('/api/engine/queue?days=7');
+        const items = (d.items || []);
+        if (cnt) cnt.textContent = `${items.length} item${items.length === 1 ? '' : 's'}`;
+        if (!box) return;
+        if (!items.length) { box.innerHTML = '<div class="muted">No activity in the last 7 days.</div>'; return; }
+        box.innerHTML = items.map(it => {
+          const p = it.product || {};
+          const thumb = (it.mediaUrl || p.imageUrl)
+            ? `<img class="pt" src="${esc(it.mediaUrl || p.imageUrl)}" alt="" loading="lazy">`
+            : `<div class="pt ph">🖼️</div>`;
+          const st = it.status || 'suggested';
+          const tm = it.postedAt ? fmtWhen(it.postedAt) : it.scheduledAt ? fmtWhen(it.scheduledAt) : (it.date || '');
+          const errTitle = st === 'failed' && it.lastError ? ` title="${esc(it.lastError)}"` : '';
+          return `<div class="eng-pc"${errTitle}>
+            ${thumb}
+            <div class="meta">
+              <div class="ch">${chanIcon(it.channel)} ${chanLabel(it.channel)}</div>
+              <div class="st s-${esc(st)}">${esc(st)}${st === 'failed' ? ' ✗' : st === 'posted' ? ' ✓' : ''}</div>
+              <div class="tm">${esc(tm)}</div>
+            </div>
+          </div>`;
+        }).join('');
+      } catch (e) {
+        if (cnt) cnt.textContent = '';
+        if (box) box.innerHTML = `<div class="muted">Couldn't load history: ${esc(e.message)}</div>`;
+      }
+    }
+
+    // ── loaders ───────────────────────────────────────────────────────────────
+    async function refreshStatus() {
+      try { state.status = await jget('/api/engine/status'); banner(''); }
+      catch (e) { banner('Engine status unavailable: ' + e.message); }
+      renderHeader();
+    }
+
+    // Cheap, non-generating today peek (used on load + on the 30s poll).
+    async function refreshToday() {
+      try {
+        const d = await jget('/api/engine/today?generate=0');
+        state.items = d.items || [];
+        banner('');
+      } catch (e) {
+        banner('Could not load today\'s suggestions: ' + e.message);
+      }
+      renderToday();
+      renderHeader(); // cost badge depends on items
+    }
+
+    // Force a fresh generation (may take 1–3 min via the local model).
+    async function doGenerate() {
+      if (state.generating) return;
+      state.generating = true;
+      renderToday();
+      banner('Generating today\'s suggestions with the local model — this can take 1–3 minutes…', true);
+      const genBtn = $('#eng-generate'); if (genBtn) { genBtn.disabled = true; genBtn.textContent = 'Generating…'; }
+      const r = await jpost('/api/engine/generate', {});
+      state.generating = false;
+      if (genBtn) { genBtn.disabled = false; genBtn.textContent = '⚡ Generate today'; }
+      if (r.error) { banner('Generation failed: ' + r.error); renderToday(); return; }
+      state.items = r.items || [];
+      if (r.note && !state.items.length) banner(r.note);
+      else banner('Suggestions ready.', true);
+      renderToday();
+      await refreshStatus();
+      await loadPipeline();
+    }
+
+    // ── auto-refresh (30s), paused when the tab is hidden ─────────────────────
+    let timer = null;
+    function tick() {
+      if (document.hidden || state.generating) return;
+      // don't stomp an open editor
+      if (Object.values(state.editing).some(Boolean)) { refreshStatus(); return; }
+      refreshStatus();
+      refreshToday();
+      loadPipeline();
+    }
+    function startTimer() { stopTimer(); timer = setInterval(tick, 30000); }
+    function stopTimer() { if (timer) { clearInterval(timer); timer = null; } }
+    const onVis = () => { if (!document.hidden) tick(); };
+    document.addEventListener('visibilitychange', onVis);
+    // stop polling once this panel is swapped out (its root leaves the DOM).
+    const mo = new MutationObserver(() => {
+      if (!document.body.contains(root)) { stopTimer(); document.removeEventListener('visibilitychange', onVis); mo.disconnect(); }
+    });
+    try { mo.observe(document.getElementById('panel') || document.body, { childList: true }); } catch {}
+
+    // ── wire header button + boot ─────────────────────────────────────────────
+    const gen = $('#eng-generate'); if (gen) gen.onclick = doGenerate;
+
+    (async function boot() {
+      await refreshStatus();
+      await refreshToday();
+      await loadPipeline();
+      startTimer();
+    })();
+  },
+};

← 0938d51 engine: core module (store/config/skeleton) + channels.postL  ·  back to Marketing Command Center  ·  engine: contrarian fixes — YT approve defaults private, edit 0210a80 →