[object Object]

← back to Norma Platform

IG multi-account posting: registry + resolver + post-to CLI over 35 linked accounts (shared META token, dry-run-gated)

6b3ec2486f04476b9bbfb293386a034303ccebf6 · 2026-08-10 11:53:05 -0700 · Steve

Files touched

Diff

commit 6b3ec2486f04476b9bbfb293386a034303ccebf6
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 11:53:05 2026 -0700

    IG multi-account posting: registry + resolver + post-to CLI over 35 linked accounts (shared META token, dry-run-gated)
---
 agents/instagram-agent/accounts.js       |  81 ++++++++
 agents/instagram-agent/accounts.json     | 335 +++++++++++++++++++++++++++++++
 agents/instagram-agent/build-registry.js | 100 +++++++++
 agents/instagram-agent/post-to.js        | 150 ++++++++++++++
 agents/instagram-agent/skills/post.js    |  17 +-
 5 files changed, 680 insertions(+), 3 deletions(-)

diff --git a/agents/instagram-agent/accounts.js b/agents/instagram-agent/accounts.js
new file mode 100644
index 0000000..3e1720d
--- /dev/null
+++ b/agents/instagram-agent/accounts.js
@@ -0,0 +1,81 @@
+/**
+ * accounts.js — multi-account resolver for the Instagram posting layer.
+ *
+ * Maps an account name/handle → the credentials needed to publish to it:
+ *   { ig_user_id, access_token, graph_host, graph_version, handle, page_name }
+ *
+ * All 35 IG-linked accounts share ONE never-expiring token (META_ACCESS_TOKEN),
+ * so posting to any of them is just "resolve the ig_user_id, reuse the token."
+ * A per-account `token` field in accounts.json overrides the shared token if a
+ * given account ever needs its own (e.g. an Instagram-Login flow account).
+ *
+ * Resolution order for the token:
+ *   1. account.token in accounts.json (per-account override)
+ *   2. process.env.META_ACCESS_TOKEN
+ *   3. ~/Projects/secrets-manager/.env  META_ACCESS_TOKEN
+ *   4. process.env.IG_ACCESS_TOKEN      (legacy single-account fallback)
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const REGISTRY_PATH = path.join(__dirname, 'accounts.json');
+
+function sharedToken() {
+  if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
+  try {
+    const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+    const line = fs.readFileSync(envPath, 'utf8')
+      .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
+    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
+  } catch { /* ignore */ }
+  return process.env.IG_ACCESS_TOKEN || '';
+}
+
+function loadRegistry() {
+  try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')); }
+  catch { return { accounts: {} }; }
+}
+
+/** Normalize "@Velvet Wallpaper" / "velvetwallpaper" / an ig_user_id → a lookup key. */
+function normalize(name) {
+  return String(name || '').trim().replace(/^@/, '').toLowerCase();
+}
+
+/** List all postable account handles. */
+function list() {
+  return Object.values(loadRegistry().accounts)
+    .map((a) => ({ handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name }));
+}
+
+/**
+ * Resolve one account → publishing credentials, or null if unknown.
+ * Accepts a handle (@x / x), a page_name, or a raw ig_user_id.
+ */
+function resolve(name) {
+  const reg = loadRegistry();
+  const key = normalize(name);
+
+  let acct = reg.accounts[key];
+  if (!acct) {
+    // fall back to matching by page_name or by ig_user_id
+    acct = Object.values(reg.accounts).find(
+      (a) => normalize(a.page_name) === key || a.ig_user_id === String(name).trim(),
+    );
+  }
+  if (!acct) return null;
+
+  const token = acct.token || sharedToken();
+  return {
+    handle: acct.handle,
+    page_name: acct.page_name,
+    ig_user_id: acct.ig_user_id,
+    access_token: token,
+    graph_host: acct.graph_host || 'https://graph.facebook.com',
+    graph_version: acct.graph_version || 'v21.0',
+    has_token: !!token,
+  };
+}
+
+module.exports = { resolve, list, normalize, loadRegistry };
diff --git a/agents/instagram-agent/accounts.json b/agents/instagram-agent/accounts.json
new file mode 100644
index 0000000..912189b
--- /dev/null
+++ b/agents/instagram-agent/accounts.json
@@ -0,0 +1,335 @@
+{
+  "generated_at": "2026-08-10T18:51:21.454Z",
+  "token_source": "META_ACCESS_TOKEN (shared, never-expiring)",
+  "pages_total": 80,
+  "accounts_postable": 35,
+  "pages_without_ig": [
+    "1800s Wallpaper",
+    "1890s Wallpaper",
+    "All News Daily",
+    "All Weather Wallpaper",
+    "As Seen in Movies",
+    "Bamboo Wallcoverings",
+    "Black Wallcovering",
+    "Brazilliance Walls & Fabrics",
+    "Bubbe ",
+    "Charleston Wallpaper Store",
+    "Chicago Wallpaper Store",
+    "DSGN TV",
+    "Design Trade Wallpaper",
+    "Designer Fabrics",
+    "DesignerWallpaperForum.com",
+    "Designers Chat",
+    "FFE Purchasing Group",
+    "Faux News Live",
+    "Flocked Wallpaper",
+    "FlockedWallpaper.com",
+    "Glass Beaded Wallcoverings",
+    "Glass Beaded Wallpaper",
+    "Glitter Walls",
+    "Grasscloth Wallpaper",
+    "Green Wallpapers",
+    "Hand Blocked Wallpaper",
+    "Handcrafted Wallpaper",
+    "Interior Designer Showroom",
+    "LAFires.org - Los Angeles Fires",
+    "Local Design Firms",
+    "Local Designer Directory",
+    "Los Angeles Fires",
+    "Madagascar Wallpaper",
+    "NOTVRequired.com",
+    "Peel and Stick Wallpaper",
+    "Raffia Wallpaper",
+    "Silk Wallpaper",
+    "To The Trade Daily",
+    "Trade Only Fabrics",
+    "Trade Only Textiles",
+    "Traditional Whimsy Home",
+    "Versace Wallpaper",
+    "Wallpaper Design Competition",
+    "WallpaperWeekly.com",
+    "Why Legalize - Marijuana"
+  ],
+  "accounts": {
+    "asseeninhotels": {
+      "handle": "asseeninhotels",
+      "ig_user_id": "17841409119139761",
+      "page_id": "453810901296460",
+      "page_name": "As Seen in Hotels",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "beverlyhillsvideos": {
+      "handle": "beverlyhillsvideos",
+      "ig_user_id": "17841408681887144",
+      "page_id": "249173031283",
+      "page_name": "Designer Wallcoverings",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "borninbeverlyhills": {
+      "handle": "borninbeverlyhills",
+      "ig_user_id": "17841408772995866",
+      "page_id": "2548395878518829",
+      "page_name": "Born in Beverly Hills",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "brazilliancewallpaper": {
+      "handle": "brazilliancewallpaper",
+      "ig_user_id": "17841407025318686",
+      "page_id": "2009198999106954",
+      "page_name": "Brazilliance Wallpaper and Fabric",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "chinoiseriewallpaper": {
+      "handle": "chinoiseriewallpaper",
+      "ig_user_id": "17841407176278551",
+      "page_id": "530286787358585",
+      "page_name": "Et Cie Chinoiserie Wall Murals",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "commercialwallcoverings": {
+      "handle": "commercialwallcoverings",
+      "ig_user_id": "17841408775091102",
+      "page_id": "101723721568114",
+      "page_name": "Commercial Wallcoverings",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "customwallcoverings": {
+      "handle": "customwallcoverings",
+      "ig_user_id": "17841408685240867",
+      "page_id": "128212609930",
+      "page_name": "Custom Wall Papers and Wallcoverings",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "designerlaboratory": {
+      "handle": "designerlaboratory",
+      "ig_user_id": "17841405610219019",
+      "page_id": "224684317687182",
+      "page_name": "Designer Laboratory",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "designerwallcoverings": {
+      "handle": "designerwallcoverings",
+      "ig_user_id": "17841400781263605",
+      "page_id": "92675029719",
+      "page_name": "DesignerWallcoverings.com",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "fabric_fridays": {
+      "handle": "fabric_fridays",
+      "ig_user_id": "17841434885415391",
+      "page_id": "105187997852752",
+      "page_name": "Fabric Fridays",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "ffepurchasing": {
+      "handle": "ffepurchasing",
+      "ig_user_id": "17841407058348607",
+      "page_id": "2054055348200975",
+      "page_name": "FFE Purchasing",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "goldleafwallpaper": {
+      "handle": "goldleafwallpaper",
+      "ig_user_id": "17841408856660866",
+      "page_id": "143671216006539",
+      "page_name": "Gold Leaf Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "grassclothwallpaper": {
+      "handle": "grassclothwallpaper",
+      "ig_user_id": "17841408868631110",
+      "page_id": "139542627030",
+      "page_name": "Wallpaper Weekly",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "hollywoodwallcoverings": {
+      "handle": "hollywoodwallcoverings",
+      "ig_user_id": "17841406772943912",
+      "page_id": "310236955763638",
+      "page_name": "Hollywood Wallcoverings",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "hospitalitywallcoverings": {
+      "handle": "hospitalitywallcoverings",
+      "ig_user_id": "17841407188578748",
+      "page_id": "112549388778107",
+      "page_name": "Hospitality Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "interiordesignervideos": {
+      "handle": "interiordesignervideos",
+      "ig_user_id": "17841408790905574",
+      "page_id": "683918978285923",
+      "page_name": "Interior Designer Videos",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "linenwallpaper": {
+      "handle": "linenwallpaper",
+      "ig_user_id": "17841408700186822",
+      "page_id": "258674951458459",
+      "page_name": "Linen Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "metallicwallpaper": {
+      "handle": "metallicwallpaper",
+      "ig_user_id": "17841408654197157",
+      "page_id": "329043417920690",
+      "page_name": "Metallic Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "papelestapiz": {
+      "handle": "papelestapiz",
+      "ig_user_id": "17841407051208925",
+      "page_id": "1640547612676556",
+      "page_name": "Papel Tapiz",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "patterndesignlab": {
+      "handle": "patterndesignlab",
+      "ig_user_id": "17841405752089037",
+      "page_id": "1714086251943136",
+      "page_name": "Pattern Design Lab",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "philliperomanodesigns": {
+      "handle": "philliperomanodesigns",
+      "ig_user_id": "17841406249858383",
+      "page_id": "1642942779256637",
+      "page_name": "Phillipe Romano",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "restorationwallpaper": {
+      "handle": "restorationwallpaper",
+      "ig_user_id": "17841408840359842",
+      "page_id": "723158284513545",
+      "page_name": "Restoration Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "retrowalls": {
+      "handle": "retrowalls",
+      "ig_user_id": "17841406464478261",
+      "page_id": "241532263064171",
+      "page_name": "Retro Walls",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "roomsettings": {
+      "handle": "roomsettings",
+      "ig_user_id": "17841408698848037",
+      "page_id": "563643740756932",
+      "page_name": "Room Settings",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "screenprintedwallpaper": {
+      "handle": "screenprintedwallpaper",
+      "ig_user_id": "17841408660869742",
+      "page_id": "1965321333759540",
+      "page_name": "Screen Printed Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "sheltermagazines": {
+      "handle": "sheltermagazines",
+      "ig_user_id": "17841406881694118",
+      "page_id": "1383580575209054",
+      "page_name": "Shelter Magazines",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "suedewallpaper": {
+      "handle": "suedewallpaper",
+      "ig_user_id": "17841408648437136",
+      "page_id": "320648328748209",
+      "page_name": "Suede Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "textilewallpaper": {
+      "handle": "textilewallpaper",
+      "ig_user_id": "17841408813467105",
+      "page_id": "242997016372360",
+      "page_name": "Textile Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "thedesignerlibrary": {
+      "handle": "thedesignerlibrary",
+      "ig_user_id": "17841408572952750",
+      "page_id": "303486766522573",
+      "page_name": "Designer Library",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "thesetdecorator": {
+      "handle": "thesetdecorator",
+      "ig_user_id": "17841406774743877",
+      "page_id": "500577630006739",
+      "page_name": "The Set Decorator",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "traditionalwhimsy": {
+      "handle": "traditionalwhimsy",
+      "ig_user_id": "17841408645959795",
+      "page_id": "188355165382826",
+      "page_name": "Traditional Whimsy",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "velvetwallpaper": {
+      "handle": "velvetwallpaper",
+      "ig_user_id": "17841408844140138",
+      "page_id": "349839162257610",
+      "page_name": "Velvet Wallpaper",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "wallpaperhistory": {
+      "handle": "wallpaperhistory",
+      "ig_user_id": "17841408790217103",
+      "page_id": "330587311005484",
+      "page_name": "Wallpaper History",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "wallpaperinstallers": {
+      "handle": "wallpaperinstallers",
+      "ig_user_id": "17841414900195265",
+      "page_id": "102819675952474",
+      "page_name": "Wallpaper Installers",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    },
+    "wallpaperwednesdays": {
+      "handle": "wallpaperwednesdays",
+      "ig_user_id": "17841409211532596",
+      "page_id": "2223776031217340",
+      "page_name": "Wallpaper Wednesdays",
+      "graph_host": "https://graph.facebook.com",
+      "graph_version": "v21.0"
+    }
+  }
+}
\ No newline at end of file
diff --git a/agents/instagram-agent/build-registry.js b/agents/instagram-agent/build-registry.js
new file mode 100644
index 0000000..76f8b65
--- /dev/null
+++ b/agents/instagram-agent/build-registry.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * build-registry.js — regenerate accounts.json from the live Meta token.
+ *
+ * Walks GET /me/accounts (all Facebook Pages the token administers), keeps the
+ * ones with a linked Instagram business account, and writes a registry mapping
+ * handle → { ig_user_id, page_id, page_name }. Every entry is postable with the
+ * shared never-expiring META_ACCESS_TOKEN + graph.facebook.com (Facebook-Login
+ * flow), which is what Norma's _ig-api already uses when IG_GRAPH_HOST is set.
+ *
+ * This is SELF-HEALING: link a new IG account to its Page in Meta Business
+ * Suite, re-run this, and the new handle appears automatically.
+ *
+ * Usage:  node build-registry.js         # writes accounts.json
+ *         node build-registry.js --print # print, don't write
+ *
+ * Token resolution (first hit wins):
+ *   process.env.META_ACCESS_TOKEN  →  ~/Projects/secrets-manager/.env
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const GRAPH = process.env.IG_GRAPH_HOST || 'https://graph.facebook.com';
+const VERSION = process.env.IG_GRAPH_VERSION || 'v21.0';
+
+function readSecretsToken() {
+  if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
+  try {
+    const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+    const line = fs.readFileSync(envPath, 'utf8')
+      .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
+    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
+  } catch { /* ignore */ }
+  return '';
+}
+
+async function main() {
+  const token = readSecretsToken();
+  if (!token) { console.error('No META_ACCESS_TOKEN found (env or secrets-manager/.env).'); process.exit(1); }
+
+  let url = `${GRAPH}/${VERSION}/me/accounts`
+    + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(token)}`;
+  const linked = [];
+  const unlinked = [];
+  let pages = 0;
+
+  while (url) {
+    const r = await fetch(url);
+    const j = await r.json();
+    if (j.error) throw new Error(`me/accounts failed: ${j.error.message}`);
+    for (const p of j.data || []) {
+      pages += 1;
+      const ig = p.instagram_business_account;
+      if (ig && ig.id) {
+        linked.push({ username: ig.username || null, ig_user_id: ig.id, page_id: p.id, page_name: p.name });
+      } else {
+        unlinked.push(p.name);
+      }
+    }
+    url = (j.paging && j.paging.next) || null;
+  }
+
+  // registry: keyed by handle (lowercased, no @) → account record
+  const accounts = {};
+  for (const a of linked.sort((x, y) => (x.username || '').localeCompare(y.username || ''))) {
+    const key = (a.username || a.page_id).toLowerCase();
+    accounts[key] = {
+      handle: a.username,
+      ig_user_id: a.ig_user_id,
+      page_id: a.page_id,
+      page_name: a.page_name,
+      graph_host: GRAPH,
+      graph_version: VERSION,
+      // token omitted → resolver uses the shared META_ACCESS_TOKEN
+    };
+  }
+
+  const registry = {
+    generated_at: new Date().toISOString(),
+    token_source: 'META_ACCESS_TOKEN (shared, never-expiring)',
+    pages_total: pages,
+    accounts_postable: Object.keys(accounts).length,
+    pages_without_ig: unlinked.sort(),
+    accounts,
+  };
+
+  if (process.argv.includes('--print')) {
+    console.log(JSON.stringify(registry, null, 2));
+    return;
+  }
+  const out = path.join(__dirname, 'accounts.json');
+  fs.writeFileSync(out, JSON.stringify(registry, null, 2));
+  console.log(`Wrote ${out}`);
+  console.log(`  postable IG accounts: ${registry.accounts_postable}`);
+  console.log(`  pages without linked IG: ${registry.pages_without_ig.length}`);
+}
+
+main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/agents/instagram-agent/post-to.js b/agents/instagram-agent/post-to.js
new file mode 100644
index 0000000..16d5d77
--- /dev/null
+++ b/agents/instagram-agent/post-to.js
@@ -0,0 +1,150 @@
+#!/usr/bin/env node
+/**
+ * post-to.js — post to ANY of Steve's Instagram accounts from one command.
+ *
+ * Every IG account is addressed by handle; credentials are resolved from
+ * accounts.json (regenerate with `node build-registry.js`) and the shared
+ * never-expiring META_ACCESS_TOKEN.
+ *
+ * SAFETY GATE: publishing to a live account is outward-facing, so this tool
+ * runs a DRY RUN by default — it resolves the account, validates inputs, and
+ * (for images) creates the media container, but STOPS before /media_publish.
+ * Pass --confirm to actually publish.
+ *
+ * Usage:
+ *   node post-to.js list
+ *   node post-to.js <account> --image <public_url> --caption "text"          # dry run
+ *   node post-to.js <account> --image <public_url> --caption "text" --confirm # LIVE
+ *   node post-to.js <account> --reel  <public_mp4_url> --caption "text" --confirm
+ *   node post-to.js <account> --story <public_url> --confirm
+ *   node post-to.js --all     --image <public_url> --caption "text" --confirm # every account
+ *
+ * <account> = handle (@velvetwallpaper / velvetwallpaper), page name, or ig_user_id.
+ */
+
+const accounts = require('./accounts');
+
+function parseArgs(argv) {
+  const a = { _: [] };
+  for (let i = 0; i < argv.length; i++) {
+    const t = argv[i];
+    if (t.startsWith('--')) {
+      const key = t.slice(2);
+      const next = argv[i + 1];
+      if (next === undefined || next.startsWith('--')) { a[key] = true; }
+      else { a[key] = next; i++; }
+    } else { a._.push(t); }
+  }
+  return a;
+}
+
+async function graph(host, version, pathPart, params) {
+  const url = `${host}/${version}/${pathPart}`;
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(params),
+  });
+  const j = await r.json();
+  if (j.error) throw new Error(j.error.message);
+  return j;
+}
+
+async function publishOne(acct, opts) {
+  const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
+  const dry = !opts.confirm;
+  const kind = opts.reel ? 'REELS' : opts.story ? 'STORIES' : 'IMAGE';
+
+  // Build the media-container params for the requested kind
+  const container = { access_token: token };
+  if (opts.reel) { container.media_type = 'REELS'; container.video_url = opts.reel; }
+  else if (opts.story) {
+    container.media_type = 'STORIES';
+    if (/\.mp4($|\?)/i.test(opts.story)) container.video_url = opts.story;
+    else container.image_url = opts.story;
+  } else { container.image_url = opts.image; }
+  if (opts.caption && kind !== 'STORIES') container.caption = opts.caption;
+
+  if (dry) {
+    return { handle: acct.handle, ig_user_id: id, kind, dry_run: true,
+      would_post: { ...container, access_token: '***' } };
+  }
+
+  // Step 1: create container
+  const c = await graph(host, ver, `${id}/media`, container);
+  // Step 2: video kinds need processing time before publish
+  if (kind !== 'IMAGE') {
+    for (let i = 0; i < 30; i++) {
+      const s = await fetch(`${host}/${ver}/${c.id}?fields=status_code&access_token=${encodeURIComponent(token)}`);
+      const sj = await s.json();
+      if (sj.status_code === 'FINISHED') break;
+      if (sj.status_code === 'ERROR') throw new Error(`container processing error for @${acct.handle}`);
+      await new Promise((res) => setTimeout(res, 4000));
+    }
+  }
+  // Step 3: publish
+  const pub = await graph(host, ver, `${id}/media_publish`, { creation_id: c.id, access_token: token });
+  // permalink (best effort)
+  let permalink = null;
+  try {
+    const p = await fetch(`${host}/${ver}/${pub.id}?fields=permalink&access_token=${encodeURIComponent(token)}`);
+    permalink = (await p.json()).permalink || null;
+  } catch { /* ignore */ }
+  return { handle: acct.handle, ig_user_id: id, kind, media_id: pub.id, permalink, posted: true };
+}
+
+async function main() {
+  const args = parseArgs(process.argv.slice(2));
+  const cmd = args._[0];
+
+  if (cmd === 'list' || args.list) {
+    const all = accounts.list();
+    console.log(`${all.length} postable Instagram accounts:\n`);
+    for (const a of all) console.log(`  @${(a.handle || '').padEnd(28)} ${a.page_name}`);
+    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.');
+    process.exit(1);
+  }
+
+  // Resolve target accounts
+  let targets;
+  if (args.all) {
+    targets = accounts.list().map((a) => accounts.resolve(a.handle));
+  } else {
+    if (!cmd) { console.error('Specify an account (or --all).'); process.exit(1); }
+    const one = accounts.resolve(cmd);
+    if (!one) { console.error(`Unknown account "${cmd}". Try: node post-to.js list`); process.exit(1); }
+    targets = [one];
+  }
+
+  const missing = targets.filter((t) => !t.has_token);
+  if (missing.length) { console.error(`No token resolved for: ${missing.map((m) => m.handle).join(', ')}`); process.exit(1); }
+
+  const dry = !args.confirm;
+  console.log(dry
+    ? `DRY RUN (no --confirm) → ${targets.length} account(s). Nothing will be published.\n`
+    : `LIVE POST → ${targets.length} account(s).\n`);
+
+  const results = [];
+  for (const t of targets) {
+    try {
+      const r = await publishOne(t, args);
+      results.push(r);
+      console.log(dry
+        ? `  ✓ @${t.handle} — dry-run OK (${r.kind})`
+        : `  ✓ @${t.handle} — posted ${r.kind} ${r.permalink || r.media_id}`);
+    } catch (e) {
+      results.push({ handle: t.handle, error: e.message });
+      console.log(`  ✗ @${t.handle} — ${e.message}`);
+    }
+  }
+  const ok = results.filter((r) => !r.error).length;
+  console.log(`\n${ok}/${results.length} ${dry ? 'validated' : 'posted'}.`);
+  if (dry) console.log('Add --confirm to publish for real.');
+}
+
+main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/agents/instagram-agent/skills/post.js b/agents/instagram-agent/skills/post.js
index 408dc52..e62e917 100644
--- a/agents/instagram-agent/skills/post.js
+++ b/agents/instagram-agent/skills/post.js
@@ -17,11 +17,22 @@
 
 const { loadCredentials } = require('../../shared/credentials');
 const ig = require('./_ig-api');
+const accounts = require('../accounts');
 
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
 
-async function getCredentials() {
+/**
+ * Resolve credentials. If `account` is given (a handle / page name / ig id),
+ * use the multi-account registry + shared META_ACCESS_TOKEN; otherwise fall
+ * back to the legacy single-account creds (DB / env).
+ */
+async function getCredentials(account) {
+  if (account) {
+    const a = accounts.resolve(account);
+    if (!a) throw new Error(`Unknown Instagram account "${account}" (run build-registry.js / see accounts.json)`);
+    return { has: a.has_token, userId: a.ig_user_id, accessToken: a.access_token, handle: a.handle };
+  }
   const creds = await loadCredentials('instagram');
   return {
     has: !!(creds?.IG_USER_ID && creds?.IG_ACCESS_TOKEN),
@@ -41,10 +52,10 @@ async function getCredentials() {
 module.exports = async function post(params) {
   const imageUrl = params.image_url || '';
   const caption = params.caption || params.text || '';
-  const { has: hasCreds, userId, accessToken } = await getCredentials();
+  const { has: hasCreds, userId, accessToken, handle } = await getCredentials(params.account);
   const simulated = !hasCreds;
 
-  console.log(`[${AGENT}] Post skill invoked — simulation=${simulated}`);
+  console.log(`[${AGENT}] Post skill invoked — account=${handle || params.account || 'default'} simulation=${simulated}`);
   console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
   if (imageUrl) console.log(`[${AGENT}] Image URL: ${imageUrl}`);
 

← 564be77 TK-10395: cap reel container poll at 12x4s so 3x-retry worst  ·  back to Norma Platform  ·  IG poster: poll container to FINISHED for images too (fixes 57ebb5d →