[object Object]

← back to Norma

instagram-agent: enable live reel/story/post publishing via graph.instagram.com (Instagram Login API)

ddf268e6a69b182a48f831b7d70c68905c4bb721 · 2026-05-19 13:37:55 -0700 · Steve Abrams

App is Instagram-Login-only (Facebook Login disabled), so all calls use graph.instagram.com not graph.facebook.com. Adds _ig-api.js helper (container create/poll/publish + long-lived token exchange) and setup-ig-token.js one-shot token installer.

Files touched

Diff

commit ddf268e6a69b182a48f831b7d70c68905c4bb721
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 13:37:55 2026 -0700

    instagram-agent: enable live reel/story/post publishing via graph.instagram.com (Instagram Login API)
    
    App is Instagram-Login-only (Facebook Login disabled), so all calls use graph.instagram.com not graph.facebook.com. Adds _ig-api.js helper (container create/poll/publish + long-lived token exchange) and setup-ig-token.js one-shot token installer.
---
 agents/instagram-agent/setup-ig-token.js |  52 +++++++++++++
 agents/instagram-agent/skills/_ig-api.js | 123 +++++++++++++++++++++++++++++
 agents/instagram-agent/skills/post.js    |  86 +++++++--------------
 agents/instagram-agent/skills/reel.js    | 129 +++++++++++--------------------
 agents/instagram-agent/skills/story.js   | 127 +++++++++++-------------------
 5 files changed, 295 insertions(+), 222 deletions(-)

diff --git a/agents/instagram-agent/setup-ig-token.js b/agents/instagram-agent/setup-ig-token.js
new file mode 100755
index 0000000..b0e22c0
--- /dev/null
+++ b/agents/instagram-agent/setup-ig-token.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/**
+ * One-shot Instagram token setup.
+ *
+ * Takes a short-lived token (from the Graph API Explorer "Generate Instagram
+ * Access Token" button) + the app secret, exchanges it for a 60-day
+ * long-lived token, resolves the Instagram user id, and writes .env.
+ *
+ * Usage:
+ *   node setup-ig-token.js <SHORT_TOKEN> <APP_SECRET>
+ *
+ * APP_ID is NOT needed — the ig_exchange_token grant only requires the secret.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const ig = require('./skills/_ig-api');
+
+async function main() {
+  const [shortToken, appSecret] = process.argv.slice(2);
+  if (!shortToken || !appSecret) {
+    console.error('Usage: node setup-ig-token.js <SHORT_TOKEN> <APP_SECRET>');
+    process.exit(1);
+  }
+
+  console.log('→ Exchanging short-lived token for a 60-day long-lived token...');
+  const exchanged = await ig.exchangeLongLived(shortToken, appSecret);
+  const longToken = exchanged.access_token;
+  const days = Math.round((exchanged.expires_in || 0) / 86400);
+  console.log(`  ok — long-lived token issued, valid ~${days} days`);
+
+  console.log('→ Resolving Instagram user id...');
+  const me = await ig.whoAmI(longToken);
+  console.log(`  ok — @${me.username} (user_id ${me.user_id})`);
+
+  const envPath = path.join(__dirname, '.env');
+  const body = [
+    '# Instagram API with Instagram Login — wallco.ai auto-posting',
+    `# generated ${new Date().toISOString()}  ·  token valid ~${days} days`,
+    `IG_USER_ID=${me.user_id}`,
+    `IG_ACCESS_TOKEN=${longToken}`,
+    '',
+  ].join('\n');
+  fs.writeFileSync(envPath, body, { mode: 0o600 });
+  console.log(`→ Wrote ${envPath} (chmod 600)`);
+  console.log('\nDone. Restart the agent:  pm2 restart norma-instagram');
+}
+
+main().catch((err) => {
+  console.error('FAILED:', err.message);
+  process.exit(1);
+});
diff --git a/agents/instagram-agent/skills/_ig-api.js b/agents/instagram-agent/skills/_ig-api.js
new file mode 100644
index 0000000..44defbe
--- /dev/null
+++ b/agents/instagram-agent/skills/_ig-api.js
@@ -0,0 +1,123 @@
+/**
+ * Instagram API helper — Instagram API with Instagram Login (2024+ flow).
+ *
+ * IMPORTANT: this project uses the *Instagram Login* product, NOT Facebook
+ * Login. The Meta app ("Commercial Wallcoverings") has Facebook Login
+ * disabled, so ALL calls go to graph.instagram.com — never graph.facebook.com.
+ *
+ * Required env (set in skills/instagram-agent/.env):
+ *   IG_USER_ID       — Instagram-scoped user id (GET /me?fields=user_id)
+ *   IG_ACCESS_TOKEN  — long-lived (60-day) Instagram access token
+ *
+ * Token lifecycle:
+ *   short-lived  → from Graph API Explorer "Generate Instagram Access Token"
+ *   long-lived   → exchangeLongLived() below (needs the app secret, once)
+ *   refresh      → refreshLongLived() — call before day 60, no secret needed
+ */
+
+const GRAPH = 'https://graph.instagram.com';
+const VERSION = 'v23.0';
+
+function hasCredentials() {
+  return !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);
+}
+
+function api(path) {
+  return `${GRAPH}/${VERSION}/${path}`;
+}
+
+/** Exchange a short-lived token for a 60-day long-lived token (needs app secret). */
+async function exchangeLongLived(shortToken, appSecret) {
+  const url = `${GRAPH}/access_token?grant_type=ig_exchange_token`
+    + `&client_secret=${encodeURIComponent(appSecret)}`
+    + `&access_token=${encodeURIComponent(shortToken)}`;
+  const r = await fetch(url);
+  const j = await r.json();
+  if (j.error) throw new Error(`IG long-lived exchange failed: ${j.error.message}`);
+  return j; // { access_token, token_type, expires_in }
+}
+
+/** Refresh a long-lived token (call before it hits 60 days; no secret needed). */
+async function refreshLongLived(longToken) {
+  const url = `${GRAPH}/refresh_access_token?grant_type=ig_refresh_token`
+    + `&access_token=${encodeURIComponent(longToken)}`;
+  const r = await fetch(url);
+  const j = await r.json();
+  if (j.error) throw new Error(`IG token refresh failed: ${j.error.message}`);
+  return j;
+}
+
+/** Resolve the Instagram-scoped user id for the current token. */
+async function whoAmI(accessToken) {
+  const url = `${api('me')}?fields=user_id,username&access_token=${encodeURIComponent(accessToken)}`;
+  const r = await fetch(url);
+  const j = await r.json();
+  if (j.error) throw new Error(`IG /me failed: ${j.error.message}`);
+  return j; // { user_id, username }
+}
+
+/** POST a media container. Returns the container id. */
+async function createContainer(igUserId, accessToken, body) {
+  const r = await fetch(api(`${igUserId}/media`), {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ ...body, access_token: accessToken }),
+  });
+  const j = await r.json();
+  if (j.error) throw new Error(`IG container create failed: ${j.error.message}`);
+  return j.id;
+}
+
+/**
+ * Poll a video container until it finishes processing (REELS / video STORIES).
+ * Returns when status_code === 'FINISHED'; throws on ERROR or timeout.
+ */
+async function waitForContainer(containerId, accessToken, { tries = 30, delayMs = 4000 } = {}) {
+  for (let i = 0; i < tries; i++) {
+    const url = `${api(containerId)}?fields=status_code,status&access_token=${encodeURIComponent(accessToken)}`;
+    const r = await fetch(url);
+    const j = await r.json();
+    if (j.error) throw new Error(`IG container status failed: ${j.error.message}`);
+    if (j.status_code === 'FINISHED') return;
+    if (j.status_code === 'ERROR') throw new Error(`IG container processing error: ${j.status || 'unknown'}`);
+    await new Promise((res) => setTimeout(res, delayMs));
+  }
+  throw new Error('IG container did not finish processing in time');
+}
+
+/** Publish a finished container. Returns the published media id. */
+async function publishContainer(igUserId, accessToken, containerId) {
+  const r = await fetch(api(`${igUserId}/media_publish`), {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ creation_id: containerId, access_token: accessToken }),
+  });
+  const j = await r.json();
+  if (j.error) throw new Error(`IG publish failed: ${j.error.message}`);
+  return j.id;
+}
+
+/** Fetch the public permalink for a published media id (best-effort). */
+async function permalinkOf(mediaId, accessToken) {
+  try {
+    const url = `${api(mediaId)}?fields=permalink&access_token=${encodeURIComponent(accessToken)}`;
+    const r = await fetch(url);
+    const j = await r.json();
+    return j.permalink || null;
+  } catch {
+    return null;
+  }
+}
+
+module.exports = {
+  GRAPH,
+  VERSION,
+  hasCredentials,
+  exchangeLongLived,
+  refreshLongLived,
+  whoAmI,
+  createContainer,
+  waitForContainer,
+  publishContainer,
+  permalinkOf,
+};
diff --git a/agents/instagram-agent/skills/post.js b/agents/instagram-agent/skills/post.js
index fbadfff..408dc52 100644
--- a/agents/instagram-agent/skills/post.js
+++ b/agents/instagram-agent/skills/post.js
@@ -16,6 +16,7 @@
  */
 
 const { loadCredentials } = require('../../shared/credentials');
+const ig = require('./_ig-api');
 
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
@@ -69,63 +70,32 @@ module.exports = async function post(params) {
     };
   }
 
-  // --- Real API call (commented out until credentials are configured) ---
-  //
-  // const igUserId = process.env.IG_USER_ID;
-  // const accessToken = process.env.IG_ACCESS_TOKEN;
-  //
-  // // Step 1: Create media container
-  // const containerUrl = `https://graph.facebook.com/v19.0/${igUserId}/media`;
-  // const containerBody = {
-  //   image_url: imageUrl,
-  //   caption,
-  //   access_token: accessToken,
-  // };
-  //
-  // const containerResponse = await fetch(containerUrl, {
-  //   method: 'POST',
-  //   headers: { 'Content-Type': 'application/json' },
-  //   body: JSON.stringify(containerBody),
-  // });
-  // const containerData = await containerResponse.json();
-  //
-  // if (containerData.error) {
-  //   throw new Error(`Instagram API error (container): ${containerData.error.message}`);
-  // }
-  //
-  // const containerId = containerData.id;
-  //
-  // // Step 2: Publish the container
-  // const publishUrl = `https://graph.facebook.com/v19.0/${igUserId}/media_publish`;
-  // const publishBody = {
-  //   creation_id: containerId,
-  //   access_token: accessToken,
-  // };
-  //
-  // const publishResponse = await fetch(publishUrl, {
-  //   method: 'POST',
-  //   headers: { 'Content-Type': 'application/json' },
-  //   body: JSON.stringify(publishBody),
-  // });
-  // const publishData = await publishResponse.json();
-  //
-  // if (publishData.error) {
-  //   throw new Error(`Instagram API error (publish): ${publishData.error.message}`);
-  // }
-  //
-  // return {
-  //   media_id: publishData.id,
-  //   container_id: containerId,
-  //   caption,
-  //   image_url: imageUrl,
-  //   permalink: null, // Fetch via GET /{media_id}?fields=permalink
-  //   media_type: 'IMAGE',
-  //   simulated: false,
-  //   posted: true,
-  //   platform: PLATFORM,
-  //   pipeline_id: params.pipeline_id || null,
-  //   created_at: new Date().toISOString(),
-  // };
+  // --- Real API call (Instagram API with Instagram Login) ---
+  if (!imageUrl) throw new Error('image_url is required to post to the feed');
 
-  return { error: 'Real API call not yet enabled. Uncomment the code above.' };
+  // Step 1: create the image container
+  const containerId = await ig.createContainer(userId, accessToken, {
+    image_url: imageUrl,
+    caption,
+  });
+  console.log(`[${AGENT}] Post container created: ${containerId}`);
+
+  // Step 2: publish (image containers are ready immediately)
+  const mediaId = await ig.publishContainer(userId, accessToken, containerId);
+  const permalink = await ig.permalinkOf(mediaId, accessToken);
+  console.log(`[${AGENT}] Post published: media=${mediaId} ${permalink || ''}`);
+
+  return {
+    media_id: mediaId,
+    container_id: containerId,
+    caption,
+    image_url: imageUrl,
+    permalink,
+    media_type: 'IMAGE',
+    simulated: false,
+    posted: true,
+    platform: PLATFORM,
+    pipeline_id: params.pipeline_id || null,
+    created_at: new Date().toISOString(),
+  };
 };
diff --git a/agents/instagram-agent/skills/reel.js b/agents/instagram-agent/skills/reel.js
index 340e4de..0e0153c 100644
--- a/agents/instagram-agent/skills/reel.js
+++ b/agents/instagram-agent/skills/reel.js
@@ -1,27 +1,19 @@
 /**
  * Instagram Reel Skill
  *
- * Creates an Instagram Reel (short-form video) via Meta Graph API.
- * Same container-then-publish flow as feed posts, but with media_type=REELS.
- * Runs in simulation mode when IG_ACCESS_TOKEN is not set.
+ * Creates an Instagram Reel (short-form video) via the Instagram API with
+ * Instagram Login. Container-then-publish flow with media_type=REELS.
+ * Video containers are polled until processing finishes before publishing.
  *
- * Real endpoints:
- *   Step 1 — Create container:
- *     POST https://graph.facebook.com/v19.0/{ig_user_id}/media
- *     Params: media_type=REELS, video_url, caption, share_to_feed, access_token
- *
- *   Step 2 — Publish:
- *     POST https://graph.facebook.com/v19.0/{ig_user_id}/media_publish
- *     Params: creation_id, access_token
+ * Runs in simulation mode when IG_USER_ID + IG_ACCESS_TOKEN are not set.
+ * All calls go to graph.instagram.com (see skills/_ig-api.js for why).
  */
 
+const ig = require('./_ig-api');
+
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
 
-function hasCredentials() {
-  return !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);
-}
-
 /**
  * @param {Object} params - Request body
  * @param {string} params.video_url - Public URL of the video file
@@ -35,7 +27,7 @@ module.exports = async function reel(params) {
   const videoUrl = params.video_url || '';
   const caption = params.caption || params.text || '';
   const shareToFeed = params.share_to_feed !== false;
-  const simulated = !hasCredentials();
+  const simulated = !ig.hasCredentials();
 
   console.log(`[${AGENT}] Reel skill invoked — simulation=${simulated}`);
   console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
@@ -64,71 +56,44 @@ module.exports = async function reel(params) {
     };
   }
 
-  // --- Real API call (commented out until credentials are configured) ---
-  //
-  // const igUserId = process.env.IG_USER_ID;
-  // const accessToken = process.env.IG_ACCESS_TOKEN;
-  //
-  // // Step 1: Create reel container
-  // const containerUrl = `https://graph.facebook.com/v19.0/${igUserId}/media`;
-  // const containerBody = {
-  //   media_type: 'REELS',
-  //   video_url: videoUrl,
-  //   caption,
-  //   share_to_feed: shareToFeed,
-  //   access_token: accessToken,
-  // };
-  // if (params.cover_url) containerBody.cover_url = params.cover_url;
-  //
-  // const containerResponse = await fetch(containerUrl, {
-  //   method: 'POST',
-  //   headers: { 'Content-Type': 'application/json' },
-  //   body: JSON.stringify(containerBody),
-  // });
-  // const containerData = await containerResponse.json();
-  //
-  // if (containerData.error) {
-  //   throw new Error(`Instagram API error (reel container): ${containerData.error.message}`);
-  // }
-  //
-  // const containerId = containerData.id;
-  //
-  // // Note: For video uploads, you may need to poll the container status
-  // // GET https://graph.facebook.com/v19.0/{containerId}?fields=status_code&access_token=TOKEN
-  // // Wait until status_code === 'FINISHED' before publishing.
-  //
-  // // Step 2: Publish the reel
-  // const publishUrl = `https://graph.facebook.com/v19.0/${igUserId}/media_publish`;
-  // const publishBody = {
-  //   creation_id: containerId,
-  //   access_token: accessToken,
-  // };
-  //
-  // const publishResponse = await fetch(publishUrl, {
-  //   method: 'POST',
-  //   headers: { 'Content-Type': 'application/json' },
-  //   body: JSON.stringify(publishBody),
-  // });
-  // const publishData = await publishResponse.json();
-  //
-  // if (publishData.error) {
-  //   throw new Error(`Instagram API error (reel publish): ${publishData.error.message}`);
-  // }
-  //
-  // return {
-  //   media_id: publishData.id,
-  //   container_id: containerId,
-  //   caption,
-  //   video_url: videoUrl,
-  //   share_to_feed: shareToFeed,
-  //   permalink: null,
-  //   media_type: 'REELS',
-  //   simulated: false,
-  //   posted: true,
-  //   platform: PLATFORM,
-  //   pipeline_id: params.pipeline_id || null,
-  //   created_at: new Date().toISOString(),
-  // };
+  // --- Real API call (Instagram API with Instagram Login) ---
+  if (!videoUrl) throw new Error('video_url is required to post a reel');
+
+  const igUserId = process.env.IG_USER_ID;
+  const accessToken = process.env.IG_ACCESS_TOKEN;
+
+  // Step 1: create the reel container
+  const containerBody = {
+    media_type: 'REELS',
+    video_url: videoUrl,
+    caption,
+    share_to_feed: shareToFeed,
+  };
+  if (params.cover_url) containerBody.cover_url = params.cover_url;
+
+  const containerId = await ig.createContainer(igUserId, accessToken, containerBody);
+  console.log(`[${AGENT}] Reel container created: ${containerId} — waiting for processing...`);
+
+  // Step 2: video must finish processing before it can be published
+  await ig.waitForContainer(containerId, accessToken);
+
+  // Step 3: publish
+  const mediaId = await ig.publishContainer(igUserId, accessToken, containerId);
+  const permalink = await ig.permalinkOf(mediaId, accessToken);
+  console.log(`[${AGENT}] Reel published: media=${mediaId} ${permalink || ''}`);
 
-  return { error: 'Real API call not yet enabled. Uncomment the code above.' };
+  return {
+    media_id: mediaId,
+    container_id: containerId,
+    caption,
+    video_url: videoUrl,
+    share_to_feed: shareToFeed,
+    permalink,
+    media_type: 'REELS',
+    simulated: false,
+    posted: true,
+    platform: PLATFORM,
+    pipeline_id: params.pipeline_id || null,
+    created_at: new Date().toISOString(),
+  };
 };
diff --git a/agents/instagram-agent/skills/story.js b/agents/instagram-agent/skills/story.js
index 1dd5830..1eebe65 100644
--- a/agents/instagram-agent/skills/story.js
+++ b/agents/instagram-agent/skills/story.js
@@ -1,27 +1,19 @@
 /**
  * Instagram Story Skill
  *
- * Creates an Instagram Story via Meta Graph API.
+ * Creates an Instagram Story via the Instagram API with Instagram Login.
  * Uses media_type=STORIES. Stories expire after 24 hours.
- * Runs in simulation mode when IG_ACCESS_TOKEN is not set.
+ * Video stories are polled until processing finishes before publishing.
  *
- * Real endpoints:
- *   Step 1 — Create container:
- *     POST https://graph.facebook.com/v19.0/{ig_user_id}/media
- *     Params: media_type=STORIES, image_url (or video_url), access_token
- *
- *   Step 2 — Publish:
- *     POST https://graph.facebook.com/v19.0/{ig_user_id}/media_publish
- *     Params: creation_id, access_token
+ * Runs in simulation mode when IG_USER_ID + IG_ACCESS_TOKEN are not set.
+ * All calls go to graph.instagram.com (see skills/_ig-api.js for why).
  */
 
+const ig = require('./_ig-api');
+
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
 
-function hasCredentials() {
-  return !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);
-}
-
 /**
  * @param {Object} params - Request body
  * @param {string} [params.image_url] - Public URL of the image (for image stories)
@@ -33,7 +25,7 @@ module.exports = async function story(params) {
   const imageUrl = params.image_url || '';
   const videoUrl = params.video_url || '';
   const mediaSource = videoUrl ? 'video' : 'image';
-  const simulated = !hasCredentials();
+  const simulated = !ig.hasCredentials();
 
   console.log(`[${AGENT}] Story skill invoked — simulation=${simulated}, source=${mediaSource}`);
   if (imageUrl) console.log(`[${AGENT}] Image URL: ${imageUrl}`);
@@ -65,72 +57,43 @@ module.exports = async function story(params) {
     };
   }
 
-  // --- Real API call (commented out until credentials are configured) ---
-  //
-  // const igUserId = process.env.IG_USER_ID;
-  // const accessToken = process.env.IG_ACCESS_TOKEN;
-  //
-  // // Step 1: Create story container
-  // const containerUrl = `https://graph.facebook.com/v19.0/${igUserId}/media`;
-  // const containerBody = {
-  //   media_type: 'STORIES',
-  //   access_token: accessToken,
-  // };
-  //
-  // if (videoUrl) {
-  //   containerBody.video_url = videoUrl;
-  // } else if (imageUrl) {
-  //   containerBody.image_url = imageUrl;
-  // } else {
-  //   throw new Error('Either image_url or video_url is required for a story');
-  // }
-  //
-  // const containerResponse = await fetch(containerUrl, {
-  //   method: 'POST',
-  //   headers: { 'Content-Type': 'application/json' },
-  //   body: JSON.stringify(containerBody),
-  // });
-  // const containerData = await containerResponse.json();
-  //
-  // if (containerData.error) {
-  //   throw new Error(`Instagram API error (story container): ${containerData.error.message}`);
-  // }
-  //
-  // const containerId = containerData.id;
-  //
-  // // Step 2: Publish the story
-  // const publishUrl = `https://graph.facebook.com/v19.0/${igUserId}/media_publish`;
-  // const publishBody = {
-  //   creation_id: containerId,
-  //   access_token: accessToken,
-  // };
-  //
-  // const publishResponse = await fetch(publishUrl, {
-  //   method: 'POST',
-  //   headers: { 'Content-Type': 'application/json' },
-  //   body: JSON.stringify(publishBody),
-  // });
-  // const publishData = await publishResponse.json();
-  //
-  // if (publishData.error) {
-  //   throw new Error(`Instagram API error (story publish): ${publishData.error.message}`);
-  // }
-  //
-  // return {
-  //   media_id: publishData.id,
-  //   container_id: containerId,
-  //   image_url: imageUrl || null,
-  //   video_url: videoUrl || null,
-  //   media_source: mediaSource,
-  //   permalink: null,
-  //   media_type: 'STORIES',
-  //   expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
-  //   simulated: false,
-  //   posted: true,
-  //   platform: PLATFORM,
-  //   pipeline_id: params.pipeline_id || null,
-  //   created_at: new Date().toISOString(),
-  // };
+  // --- Real API call (Instagram API with Instagram Login) ---
+  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;
+  else containerBody.image_url = imageUrl;
+
+  const containerId = await ig.createContainer(igUserId, accessToken, containerBody);
+  console.log(`[${AGENT}] Story container created: ${containerId}`);
+
+  // Step 2: video stories must finish processing before publishing
+  if (videoUrl) await ig.waitForContainer(containerId, accessToken);
+
+  // Step 3: publish
+  const mediaId = await ig.publishContainer(igUserId, accessToken, containerId);
+  const permalink = await ig.permalinkOf(mediaId, accessToken);
+  console.log(`[${AGENT}] Story published: media=${mediaId} ${permalink || ''}`);
 
-  return { error: 'Real API call not yet enabled. Uncomment the code above.' };
+  return {
+    media_id: mediaId,
+    container_id: containerId,
+    image_url: imageUrl || null,
+    video_url: videoUrl || null,
+    media_source: mediaSource,
+    permalink,
+    media_type: 'STORIES',
+    expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+    simulated: false,
+    posted: true,
+    platform: PLATFORM,
+    pipeline_id: params.pipeline_id || null,
+    created_at: new Date().toISOString(),
+  };
 };

← 479708b snapshot: 12 file(s) changed, ~12 modified  ·  back to Norma  ·  fix(types): unblock SocialTab render, IntegrationsTab metada 720506c →