[object Object]

← back to Norma Platform

IG poster hardening: carousel (2-10 img) posts, verify/health-sweep, --all throttle, follower-ranked list, account param wired into reel+story (registry-first, legacy env preserved), POSTING.md docs

5dd450b1a436d9edda1ba7059f262867fe49bedc · 2026-08-10 12:18:56 -0700 · Steve

Files touched

Diff

commit 5dd450b1a436d9edda1ba7059f262867fe49bedc
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 12:18:56 2026 -0700

    IG poster hardening: carousel (2-10 img) posts, verify/health-sweep, --all throttle, follower-ranked list, account param wired into reel+story (registry-first, legacy env preserved), POSTING.md docs
---
 agents/instagram-agent/POSTING.md        |  64 ++++++++++++++
 agents/instagram-agent/accounts.js       |  34 +++++++-
 agents/instagram-agent/accounts.json     | 142 +++++++++++++++++++++++--------
 agents/instagram-agent/build-registry.js |  13 +++
 agents/instagram-agent/post-to.js        | 102 ++++++++++++++++++++--
 agents/instagram-agent/skills/reel.js    |  26 ++----
 agents/instagram-agent/skills/story.js   |  25 ++++--
 7 files changed, 337 insertions(+), 69 deletions(-)

diff --git a/agents/instagram-agent/POSTING.md b/agents/instagram-agent/POSTING.md
new file mode 100644
index 0000000..5dd1361
--- /dev/null
+++ b/agents/instagram-agent/POSTING.md
@@ -0,0 +1,64 @@
+# Instagram posting — multi-account (35 accounts, one command)
+
+All 35 IG-linked accounts post through **one never-expiring token**
+(`META_ACCESS_TOKEN` in `~/Projects/secrets-manager/.env`) via the Facebook Graph
+Content Publishing API. No per-account tokens, no refresh cron.
+
+## CLI — `post-to.js`
+
+```bash
+cd ~/Projects/Norma/agents/instagram-agent
+
+node post-to.js list                 # all 35, ranked by followers
+node post-to.js verify <account>     # read-only: prove the token reaches it
+node post-to.js verify --verify-all  # health-sweep every account
+
+# POST (dry-run by default — add --confirm to actually publish)
+node post-to.js <account> --image  <public_url> --caption "…" --confirm     # single image
+node post-to.js <account> --images "u1,u2,u3"    --caption "…" --confirm     # carousel (2–10)
+node post-to.js <account> --reel   <public_mp4>  --caption "…" --confirm     # reel
+node post-to.js <account> --story  <public_url>                 --confirm     # story
+node post-to.js --all      --image <public_url>  --caption "…" --confirm     # fan out to ALL 35
+```
+
+`<account>` = handle (`velvetwallpaper` / `@velvetwallpaper`), page name
+(`"Velvet Wallpaper"`), or the raw `ig_user_id`.
+
+### Safety
+- **Dry-run is the default.** Nothing publishes without `--confirm` — the
+  outward-facing gate is built into the tool.
+- `--all` throttles 1.5s/account (each account has its own ~50-posts/24h API limit).
+- Media URLs must be **public** (Meta fetches them server-side). Shopify CDN URLs work.
+
+## HTTP — `:9810` (agent-base, Basic-Auth)
+
+The `post`, `reel`, and `story` skills accept an `account` field, so any fleet
+agent can publish to a specific handle:
+
+```bash
+curl -s -u admin:$PASS -X POST http://127.0.0.1:9810/api/skill/post \
+  -H 'Content-Type: application/json' \
+  -d '{"account":"velvetwallpaper","image_url":"https://…","caption":"…"}'
+```
+
+Account resolution is **registry-first, env-fallback**:
+1. handle in `accounts.json`  → shared `META_ACCESS_TOKEN`
+2. `dw`                        → `IG_USER_ID` + `IG_ACCESS_TOKEN` (legacy)
+3. `phillipe-romano` etc.      → `IG_USER_ID_<SUFFIX>` + `IG_ACCESS_TOKEN_<SUFFIX>`
+   (the dw-marketing-reels convention — unchanged)
+
+## Registry — `accounts.json`
+
+Regenerate from the live token (self-healing — link a new IG in Meta Business
+Suite and it appears):
+
+```bash
+node build-registry.js            # refresh handles/ids
+node build-registry.js --stats    # also pull follower/post counts (35 extra GETs)
+```
+
+## Not yet postable
+- The 45 Facebook Pages with **no linked IG account** (see
+  `UNLINKED-PAGES-CHECKLIST.md`) — they'd need IG accounts *created* first.
+- `@hospitalityfabrics` — exists (360 followers, 177 posts) but isn't connected
+  to a Page, so the API can't reach it. Parked on a Page decision.
diff --git a/agents/instagram-agent/accounts.js b/agents/instagram-agent/accounts.js
index 3e1720d..3069df1 100644
--- a/agents/instagram-agent/accounts.js
+++ b/agents/instagram-agent/accounts.js
@@ -43,10 +43,11 @@ function normalize(name) {
   return String(name || '').trim().replace(/^@/, '').toLowerCase();
 }
 
-/** List all postable account handles. */
+/** List all postable account handles (with counts when the registry has them). */
 function list() {
   return Object.values(loadRegistry().accounts)
-    .map((a) => ({ handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name }));
+    .map((a) => ({ handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name,
+      followers: a.followers_count, posts: a.media_count }));
 }
 
 /**
@@ -78,4 +79,31 @@ function resolve(name) {
   };
 }
 
-module.exports = { resolve, list, normalize, loadRegistry };
+/**
+ * Skill-facing resolver used by post/reel/story. Merges TWO addressing schemes:
+ *   1. handle-registry (this file's accounts.json) → shared META_ACCESS_TOKEN
+ *   2. legacy env-suffix scheme used by dw-marketing-reels:
+ *        'dw'              → IG_USER_ID + IG_ACCESS_TOKEN
+ *        'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
+ * Registry wins first; the env scheme is the fallback so existing reel callers
+ * keep working unchanged. Returns { account, userId, accessToken, source }.
+ */
+function resolveSkillAccount(account) {
+  const acct = (account || 'dw').toString().trim() || 'dw';
+  const reg = resolve(acct);
+  if (reg && reg.has_token) {
+    return { account: reg.handle || acct, userId: reg.ig_user_id, accessToken: reg.access_token, source: 'registry' };
+  }
+  if (acct === 'dw') {
+    return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN, source: 'env' };
+  }
+  const suffix = acct.toUpperCase().replace(/-/g, '_');
+  return {
+    account: acct,
+    userId: process.env[`IG_USER_ID_${suffix}`],
+    accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
+    source: 'env',
+  };
+}
+
+module.exports = { resolve, resolveSkillAccount, list, normalize, loadRegistry };
diff --git a/agents/instagram-agent/accounts.json b/agents/instagram-agent/accounts.json
index 912189b..d1913f2 100644
--- a/agents/instagram-agent/accounts.json
+++ b/agents/instagram-agent/accounts.json
@@ -1,5 +1,5 @@
 {
-  "generated_at": "2026-08-10T18:51:21.454Z",
+  "generated_at": "2026-08-10T19:18:25.518Z",
   "token_source": "META_ACCESS_TOKEN (shared, never-expiring)",
   "pages_total": 80,
   "accounts_postable": 35,
@@ -57,7 +57,9 @@
       "page_id": "453810901296460",
       "page_name": "As Seen in Hotels",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 642,
+      "media_count": 668
     },
     "beverlyhillsvideos": {
       "handle": "beverlyhillsvideos",
@@ -65,7 +67,9 @@
       "page_id": "249173031283",
       "page_name": "Designer Wallcoverings",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 10,
+      "media_count": 23
     },
     "borninbeverlyhills": {
       "handle": "borninbeverlyhills",
@@ -73,7 +77,9 @@
       "page_id": "2548395878518829",
       "page_name": "Born in Beverly Hills",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 51,
+      "media_count": 25
     },
     "brazilliancewallpaper": {
       "handle": "brazilliancewallpaper",
@@ -81,7 +87,9 @@
       "page_id": "2009198999106954",
       "page_name": "Brazilliance Wallpaper and Fabric",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 216,
+      "media_count": 69
     },
     "chinoiseriewallpaper": {
       "handle": "chinoiseriewallpaper",
@@ -89,7 +97,9 @@
       "page_id": "530286787358585",
       "page_name": "Et Cie Chinoiserie Wall Murals",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 534,
+      "media_count": 128
     },
     "commercialwallcoverings": {
       "handle": "commercialwallcoverings",
@@ -97,7 +107,9 @@
       "page_id": "101723721568114",
       "page_name": "Commercial Wallcoverings",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 183,
+      "media_count": 690
     },
     "customwallcoverings": {
       "handle": "customwallcoverings",
@@ -105,7 +117,9 @@
       "page_id": "128212609930",
       "page_name": "Custom Wall Papers and Wallcoverings",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 135,
+      "media_count": 710
     },
     "designerlaboratory": {
       "handle": "designerlaboratory",
@@ -113,7 +127,9 @@
       "page_id": "224684317687182",
       "page_name": "Designer Laboratory",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 146,
+      "media_count": 531
     },
     "designerwallcoverings": {
       "handle": "designerwallcoverings",
@@ -121,7 +137,9 @@
       "page_id": "92675029719",
       "page_name": "DesignerWallcoverings.com",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 14929,
+      "media_count": 5456
     },
     "fabric_fridays": {
       "handle": "fabric_fridays",
@@ -129,7 +147,9 @@
       "page_id": "105187997852752",
       "page_name": "Fabric Fridays",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 16,
+      "media_count": 74
     },
     "ffepurchasing": {
       "handle": "ffepurchasing",
@@ -137,7 +157,9 @@
       "page_id": "2054055348200975",
       "page_name": "FFE Purchasing",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 152,
+      "media_count": 127
     },
     "goldleafwallpaper": {
       "handle": "goldleafwallpaper",
@@ -145,7 +167,9 @@
       "page_id": "143671216006539",
       "page_name": "Gold Leaf Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 178,
+      "media_count": 74
     },
     "grassclothwallpaper": {
       "handle": "grassclothwallpaper",
@@ -153,7 +177,9 @@
       "page_id": "139542627030",
       "page_name": "Wallpaper Weekly",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 386,
+      "media_count": 18
     },
     "hollywoodwallcoverings": {
       "handle": "hollywoodwallcoverings",
@@ -161,7 +187,9 @@
       "page_id": "310236955763638",
       "page_name": "Hollywood Wallcoverings",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 107,
+      "media_count": 489
     },
     "hospitalitywallcoverings": {
       "handle": "hospitalitywallcoverings",
@@ -169,7 +197,9 @@
       "page_id": "112549388778107",
       "page_name": "Hospitality Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 1248,
+      "media_count": 1130
     },
     "interiordesignervideos": {
       "handle": "interiordesignervideos",
@@ -177,7 +207,9 @@
       "page_id": "683918978285923",
       "page_name": "Interior Designer Videos",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 172,
+      "media_count": 25
     },
     "linenwallpaper": {
       "handle": "linenwallpaper",
@@ -185,7 +217,9 @@
       "page_id": "258674951458459",
       "page_name": "Linen Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 190,
+      "media_count": 57
     },
     "metallicwallpaper": {
       "handle": "metallicwallpaper",
@@ -193,7 +227,9 @@
       "page_id": "329043417920690",
       "page_name": "Metallic Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 11,
+      "media_count": 4
     },
     "papelestapiz": {
       "handle": "papelestapiz",
@@ -201,7 +237,9 @@
       "page_id": "1640547612676556",
       "page_name": "Papel Tapiz",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 92,
+      "media_count": 737
     },
     "patterndesignlab": {
       "handle": "patterndesignlab",
@@ -209,7 +247,9 @@
       "page_id": "1714086251943136",
       "page_name": "Pattern Design Lab",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 284,
+      "media_count": 336
     },
     "philliperomanodesigns": {
       "handle": "philliperomanodesigns",
@@ -217,7 +257,9 @@
       "page_id": "1642942779256637",
       "page_name": "Phillipe Romano",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 130,
+      "media_count": 262
     },
     "restorationwallpaper": {
       "handle": "restorationwallpaper",
@@ -225,7 +267,9 @@
       "page_id": "723158284513545",
       "page_name": "Restoration Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 223,
+      "media_count": 188
     },
     "retrowalls": {
       "handle": "retrowalls",
@@ -233,7 +277,9 @@
       "page_id": "241532263064171",
       "page_name": "Retro Walls",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 172,
+      "media_count": 631
     },
     "roomsettings": {
       "handle": "roomsettings",
@@ -241,7 +287,9 @@
       "page_id": "563643740756932",
       "page_name": "Room Settings",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 1558,
+      "media_count": 671
     },
     "screenprintedwallpaper": {
       "handle": "screenprintedwallpaper",
@@ -249,7 +297,9 @@
       "page_id": "1965321333759540",
       "page_name": "Screen Printed Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 59,
+      "media_count": 22
     },
     "sheltermagazines": {
       "handle": "sheltermagazines",
@@ -257,7 +307,9 @@
       "page_id": "1383580575209054",
       "page_name": "Shelter Magazines",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 732,
+      "media_count": 2213
     },
     "suedewallpaper": {
       "handle": "suedewallpaper",
@@ -265,7 +317,9 @@
       "page_id": "320648328748209",
       "page_name": "Suede Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 41,
+      "media_count": 6
     },
     "textilewallpaper": {
       "handle": "textilewallpaper",
@@ -273,7 +327,9 @@
       "page_id": "242997016372360",
       "page_name": "Textile Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 149,
+      "media_count": 165
     },
     "thedesignerlibrary": {
       "handle": "thedesignerlibrary",
@@ -281,7 +337,9 @@
       "page_id": "303486766522573",
       "page_name": "Designer Library",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 194,
+      "media_count": 954
     },
     "thesetdecorator": {
       "handle": "thesetdecorator",
@@ -289,7 +347,9 @@
       "page_id": "500577630006739",
       "page_name": "The Set Decorator",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 1540,
+      "media_count": 1116
     },
     "traditionalwhimsy": {
       "handle": "traditionalwhimsy",
@@ -297,7 +357,9 @@
       "page_id": "188355165382826",
       "page_name": "Traditional Whimsy",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 3,
+      "media_count": 360
     },
     "velvetwallpaper": {
       "handle": "velvetwallpaper",
@@ -305,7 +367,9 @@
       "page_id": "349839162257610",
       "page_name": "Velvet Wallpaper",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 9,
+      "media_count": 9
     },
     "wallpaperhistory": {
       "handle": "wallpaperhistory",
@@ -313,7 +377,9 @@
       "page_id": "330587311005484",
       "page_name": "Wallpaper History",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 53,
+      "media_count": 658
     },
     "wallpaperinstallers": {
       "handle": "wallpaperinstallers",
@@ -321,7 +387,9 @@
       "page_id": "102819675952474",
       "page_name": "Wallpaper Installers",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 1675,
+      "media_count": 632
     },
     "wallpaperwednesdays": {
       "handle": "wallpaperwednesdays",
@@ -329,7 +397,9 @@
       "page_id": "2223776031217340",
       "page_name": "Wallpaper Wednesdays",
       "graph_host": "https://graph.facebook.com",
-      "graph_version": "v21.0"
+      "graph_version": "v21.0",
+      "followers_count": 1083,
+      "media_count": 1324
     }
   }
 }
\ No newline at end of file
diff --git a/agents/instagram-agent/build-registry.js b/agents/instagram-agent/build-registry.js
index 76f8b65..d6e9daa 100644
--- a/agents/instagram-agent/build-registry.js
+++ b/agents/instagram-agent/build-registry.js
@@ -62,6 +62,18 @@ async function main() {
     url = (j.paging && j.paging.next) || null;
   }
 
+  // Optional: enrich each account with live follower/post counts (--stats).
+  // Costs one Graph GET per account, so it's opt-in, not on every rebuild.
+  const withStats = process.argv.includes('--stats');
+  const statsFor = async (igId) => {
+    if (!withStats) return {};
+    try {
+      const r = await fetch(`${GRAPH}/${VERSION}/${igId}?fields=followers_count,media_count&access_token=${encodeURIComponent(token)}`);
+      const j = await r.json();
+      return j.error ? {} : { followers_count: j.followers_count, media_count: j.media_count };
+    } catch { return {}; }
+  };
+
   // registry: keyed by handle (lowercased, no @) → account record
   const accounts = {};
   for (const a of linked.sort((x, y) => (x.username || '').localeCompare(y.username || ''))) {
@@ -73,6 +85,7 @@ async function main() {
       page_name: a.page_name,
       graph_host: GRAPH,
       graph_version: VERSION,
+      ...(await statsFor(a.ig_user_id)),
       // token omitted → resolver uses the shared META_ACCESS_TOKEN
     };
   }
diff --git a/agents/instagram-agent/post-to.js b/agents/instagram-agent/post-to.js
index ce5bf4f..84f3824 100644
--- a/agents/instagram-agent/post-to.js
+++ b/agents/instagram-agent/post-to.js
@@ -50,9 +50,74 @@ async function graph(host, version, pathPart, params) {
   return j;
 }
 
-async function publishOne(acct, opts) {
+async function graphGet(host, version, pathPart, token, fields) {
+  const url = `${host}/${version}/${pathPart}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(token)}`;
+  const r = await fetch(url);
+  const j = await r.json();
+  if (j.error) throw new Error(j.error.message);
+  return j;
+}
+
+/** Poll a container until FINISHED (throws on ERROR / timeout). */
+async function waitFinished(host, ver, containerId, token, { tries = 30, delay = 3000 } = {}) {
+  for (let i = 0; i < tries; i++) {
+    const s = await fetch(`${host}/${ver}/${containerId}?fields=status_code&access_token=${encodeURIComponent(token)}`);
+    const sj = await s.json();
+    if (sj.status_code === 'FINISHED') return;
+    if (sj.status_code === 'ERROR') throw new Error('container processing error');
+    await new Promise((res) => setTimeout(res, delay));
+  }
+  throw new Error('container never reached FINISHED');
+}
+
+/** Confirm the token can actually reach an account (read its profile fields). */
+async function verifyOne(acct) {
+  const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
+  const p = await graphGet(host, ver, id, token, 'username,followers_count,media_count');
+  return { handle: acct.handle, ig_user_id: id, username: p.username,
+    followers: p.followers_count, posts: p.media_count, reachable: true };
+}
+
+/** Carousel (2–10 images): child containers → parent CAROUSEL container → publish. */
+async function publishCarousel(acct, imageUrls, caption, dry) {
   const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
+  if (imageUrls.length < 2 || imageUrls.length > 10) {
+    throw new Error(`carousel needs 2–10 images (got ${imageUrls.length})`);
+  }
+  if (dry) {
+    return { handle: acct.handle, ig_user_id: id, kind: 'CAROUSEL', dry_run: true,
+      would_post: { children: imageUrls.length, caption } };
+  }
+  // Step 1: a child container per image (is_carousel_item)
+  const childIds = [];
+  for (const url of imageUrls) {
+    const c = await graph(host, ver, `${id}/media`, { image_url: url, is_carousel_item: true, access_token: token });
+    await waitFinished(host, ver, c.id, token, { tries: 15, delay: 2000 });
+    childIds.push(c.id);
+  }
+  // Step 2: parent CAROUSEL container
+  const parent = await graph(host, ver, `${id}/media`, {
+    media_type: 'CAROUSEL', children: childIds.join(','), caption, access_token: token,
+  });
+  await waitFinished(host, ver, parent.id, token, { tries: 15, delay: 2000 });
+  // Step 3: publish
+  const pub = await graph(host, ver, `${id}/media_publish`, { creation_id: parent.id, access_token: token });
+  let permalink = null;
+  try { permalink = (await graphGet(host, ver, pub.id, token, 'permalink')).permalink || null; } catch { /* ignore */ }
+  return { handle: acct.handle, ig_user_id: id, kind: 'CAROUSEL', children: childIds.length,
+    media_id: pub.id, permalink, posted: true };
+}
+
+async function publishOne(acct, opts) {
   const dry = !opts.confirm;
+
+  // Carousel: --images "url1,url2,..." (2–10 images)
+  if (opts.images) {
+    const urls = String(opts.images).split(',').map((s) => s.trim()).filter(Boolean);
+    return publishCarousel(acct, urls, opts.caption || '', dry);
+  }
+
+  const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
   const kind = opts.reel ? 'REELS' : opts.story ? 'STORIES' : 'IMAGE';
 
   // Build the media-container params for the requested kind
@@ -104,15 +169,34 @@ async function main() {
   const cmd = args._[0];
 
   if (cmd === 'list' || args.list) {
-    const all = accounts.list();
+    const all = accounts.list().sort((a, b) => (b.followers || 0) - (a.followers || 0));
     console.log(`${all.length} postable Instagram accounts:\n`);
-    for (const a of all) console.log(`  @${(a.handle || '').padEnd(28)} ${a.page_name}`);
+    for (const a of all) {
+      const stat = a.followers != null ? `  ${String(a.followers).padStart(6)} followers` : '';
+      console.log(`  @${(a.handle || '').padEnd(28)}${stat}  ${a.page_name}`);
+    }
+    return;
+  }
+
+  // verify — prove the token can reach an account (or all of them). Read-only.
+  if (cmd === 'verify' || args.verify || args['verify-all']) {
+    const targets = (args['verify-all'] || args.all || (cmd === 'verify' && !args._[1]))
+      ? accounts.list().map((a) => accounts.resolve(a.handle))
+      : [accounts.resolve(args._[1] || cmd)].filter(Boolean);
+    if (!targets.length) { console.error('Unknown account. Try: node post-to.js list'); process.exit(1); }
+    console.log(`Verifying ${targets.length} account(s) — read-only:\n`);
+    let ok = 0;
+    for (const t of targets) {
+      try { const v = await verifyOne(t); ok++; console.log(`  ✓ @${(v.handle||'').padEnd(26)} followers=${v.followers} posts=${v.posts}`); }
+      catch (e) { console.log(`  ✗ @${(t.handle||'').padEnd(26)} ${e.message}`); }
+    }
+    console.log(`\n${ok}/${targets.length} reachable with the shared token.`);
     return;
   }
 
-  if (!args.image && !args.reel && !args.story) {
-    console.error('Nothing to post. Provide --image <url> | --reel <mp4Url> | --story <url>.');
-    console.error('Run `node post-to.js list` to see accounts.');
+  if (!args.image && !args.images && !args.reel && !args.story) {
+    console.error('Nothing to post. Provide --image <url> | --images "u1,u2,..." | --reel <mp4Url> | --story <url>.');
+    console.error('Run `node post-to.js list` to see accounts, or `verify <account>` to health-check.');
     process.exit(1);
   }
 
@@ -136,7 +220,8 @@ async function main() {
     : `LIVE POST → ${targets.length} account(s).\n`);
 
   const results = [];
-  for (const t of targets) {
+  for (let ti = 0; ti < targets.length; ti++) {
+    const t = targets[ti];
     try {
       const r = await publishOne(t, args);
       results.push(r);
@@ -147,6 +232,9 @@ async function main() {
       results.push({ handle: t.handle, error: e.message });
       console.log(`  ✗ @${t.handle} — ${e.message}`);
     }
+    // Throttle live fan-out so a --all sweep doesn't hammer the Graph API.
+    // (Each account has its own ~50-posts/24h publishing limit; 1/account is safe.)
+    if (!dry && ti < targets.length - 1) await new Promise((res) => setTimeout(res, 1500));
   }
   const ok = results.filter((r) => !r.error).length;
   console.log(`\n${ok}/${results.length} ${dry ? 'validated' : 'posted'}.`);
diff --git a/agents/instagram-agent/skills/reel.js b/agents/instagram-agent/skills/reel.js
index 1b1e5d3..312b7a6 100644
--- a/agents/instagram-agent/skills/reel.js
+++ b/agents/instagram-agent/skills/reel.js
@@ -10,6 +10,7 @@
  */
 
 const ig = require('./_ig-api');
+const accounts = require('../accounts');
 
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
@@ -17,29 +18,20 @@ const PLATFORM = 'instagram';
 /**
  * Resolve the IG credential pair for a given account id.
  *
- * The dw-marketing-reels publisher (scripts/publish-social.mjs) tags every
- * reel with an `account` id ('dw', 'phillipe-romano', …) and names its own
- * per-account auth header env as IG_AGENT_AUTH_<ID> where
- * <ID> = account.toUpperCase().replace(/-/g, '_'). We MIRROR that exact
- * naming here for the IG token pair so the two sides agree:
+ * Delegates to the shared resolver (accounts.resolveSkillAccount), which tries
+ * the handle-registry first (35 accounts on the shared META_ACCESS_TOKEN) and
+ * falls back to the dw-marketing-reels env-suffix scheme:
  *
- *   account 'dw'              → IG_USER_ID           + IG_ACCESS_TOKEN  (existing)
- *   account 'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
+ *   account 'velvetwallpaper'  → registry → shared META token   (NEW)
+ *   account 'dw'               → IG_USER_ID + IG_ACCESS_TOKEN    (legacy)
+ *   account 'phillipe-romano'  → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
  *
  * Returns { account, userId, accessToken } — userId/accessToken are undefined
  * when that account has no token configured (caller returns no-creds-for-account).
  */
 function resolveAccount(account) {
-  const acct = (account || 'dw').toString().trim() || 'dw';
-  if (acct === 'dw') {
-    return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN };
-  }
-  const suffix = acct.toUpperCase().replace(/-/g, '_');
-  return {
-    account: acct,
-    userId: process.env[`IG_USER_ID_${suffix}`],
-    accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
-  };
+  const r = accounts.resolveSkillAccount(account);
+  return { account: r.account, userId: r.userId, accessToken: r.accessToken };
 }
 
 /**
diff --git a/agents/instagram-agent/skills/story.js b/agents/instagram-agent/skills/story.js
index 1eebe65..3f72696 100644
--- a/agents/instagram-agent/skills/story.js
+++ b/agents/instagram-agent/skills/story.js
@@ -10,6 +10,7 @@
  */
 
 const ig = require('./_ig-api');
+const accounts = require('../accounts');
 
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
@@ -18,6 +19,7 @@ const PLATFORM = 'instagram';
  * @param {Object} params - Request body
  * @param {string} [params.image_url] - Public URL of the image (for image stories)
  * @param {string} [params.video_url] - Public URL of the video (for video stories)
+ * @param {string} [params.account] - target account (handle / 'dw' / 'phillipe-romano')
  * @param {string} [params.pipeline_id] - Pipeline entry ID to link
  * @returns {Promise<Object>}
  */
@@ -25,9 +27,23 @@ module.exports = async function story(params) {
   const imageUrl = params.image_url || '';
   const videoUrl = params.video_url || '';
   const mediaSource = videoUrl ? 'video' : 'image';
-  const simulated = !ig.hasCredentials();
+  // Resolve account (registry-first, env fallback). Cross-brand-leak gate: a
+  // NON-'dw' account with no token must not fall through to DW's token.
+  const resolved = accounts.resolveSkillAccount(params.account);
+  const account = resolved.account;
+  const igUserId = resolved.userId;
+  const accessToken = resolved.accessToken;
+  const hasCreds = !!(igUserId && accessToken);
+  const simulated = !hasCreds;
 
-  console.log(`[${AGENT}] Story skill invoked — simulation=${simulated}, source=${mediaSource}`);
+  if (account !== 'dw' && !hasCreds) {
+    console.log(`[${AGENT}] No credentials for account '${account}' — refusing to fall through to DW.`);
+    return { status: 'no-creds-for-account', account, posted: false, simulated: false, platform: PLATFORM,
+      image_url: imageUrl || null, video_url: videoUrl || null, pipeline_id: params.pipeline_id || null,
+      created_at: new Date().toISOString() };
+  }
+
+  console.log(`[${AGENT}] Story skill invoked — account=${account} simulation=${simulated}, source=${mediaSource}`);
   if (imageUrl) console.log(`[${AGENT}] Image URL: ${imageUrl}`);
   if (videoUrl) console.log(`[${AGENT}] Video URL: ${videoUrl}`);
 
@@ -57,14 +73,11 @@ module.exports = async function story(params) {
     };
   }
 
-  // --- Real API call (Instagram API with Instagram Login) ---
+  // --- Real API call ---  (igUserId/accessToken resolved above for `account`)
   if (!videoUrl && !imageUrl) {
     throw new Error('Either image_url or video_url is required for a story');
   }
 
-  const igUserId = process.env.IG_USER_ID;
-  const accessToken = process.env.IG_ACCESS_TOKEN;
-
   // Step 1: create the story container
   const containerBody = { media_type: 'STORIES' };
   if (videoUrl) containerBody.video_url = videoUrl;

← dfc3f02 IG: verified inside Business Suite — 35 = complete existing  ·  back to Norma Platform  ·  IG poster: --product <handle> pulls a real DW product (live 8a9959f →