← 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
A agents/instagram-agent/setup-ig-token.jsA agents/instagram-agent/skills/_ig-api.jsM agents/instagram-agent/skills/post.jsM agents/instagram-agent/skills/reel.jsM agents/instagram-agent/skills/story.js
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 →