Almost everyone asking this question is looking for an Instagram MCP server. A simpler answer works better. This walks through the whole thing, from an empty Meta developer account to a working post, all through Claude Code.
By the End of This Post You Will Be Able To
- Explain why an MCP server is the wrong tool for this, and what to build instead
- Set up a Meta developer app that can publish to your Instagram account
- Get a working access token, which is the password your script uses to post
- Publish an image or a Reel to Instagram from a Python script
- Keep that token alive so your setup does not break in 60 days
- Have Claude Code run the whole thing for you, with a review step you control
You don’t need to have used the Meta Graph API before, and you don’t need to understand one line of Python.
Fair warning, though: this process gets into the weeds a bit and may look intimidating at first. Don’t worry, this post will walk you through it. If you get stuck, you can always point Claude Code at this post and ask it to help you out.
The Fastest Path: Hand This Post to Claude Code
You don’t have to write any of the code in this post. Claude Code can read the post and build it for you.
Open Claude Code, in whichever form you use, and give it this:
Read https://digitaljavelina.com/connect-instagram-to-claude-code/ and build the
Instagram posting script it describes. Put the script in ~/instagram-poster/ and
my credentials in a .env file under .config/instagram-poster/ in my home folder,
never in the script itself. Tell me the full path of both. Then write the skill
file into ~/.claude/skills/ so it works from anywhere, and make sure it runs the
script in preview mode first and never posts without asking me.
That’s the whole instruction. Claude fetches the post, reads the same code blocks you’re about to scroll past, and writes the files.
Three terms in that prompt, since all three come up throughout this post.
An API is the way one program talks to another. When you use Instagram on your phone, you tap buttons. When a script uses Instagram, it sends messages to an address Instagram publishes for exactly that purpose. Same service, different door.
An .env file is an ordinary text file that holds your passwords and keys, one per line, kept separate from your code. Programs read it when they start. The name is just a dot followed by “env,” short for environment, and the leading dot is a long-standing convention for a file you aren’t meant to look at day to day. It exists so your secrets never end up pasted inside a script you might share, publish, or screenshot.
A skill is a markdown file that teaches Claude Code how you want a particular job done. It has a short description at the top, and when your request matches that description, Claude reads the rest and follows it. Plain English instructions, in a file you control. There’s a section near the end on what to put in yours.
This prompt works the same on Mac and Windows. Claude Code already knows which operating system it’s running on, so it puts the file in the right place and writes the right commands without being told. That’s why the prompt says “my home folder” rather than naming a path: on a Mac it lands in ~/.config/instagram-poster/, on Windows in C:\Users\YourName\.config\instagram-poster\. Asking Claude to tell you the full path means you can find the file later without guessing.
Be clear about who does what, because this is where people get stuck. The work splits cleanly in two:
Claude does all the code. The Python script, the .env file, the skill file, the error handling, the token refresh. Every line.
You do all the clicking. Create the Meta app, add the permissions, accept the tester invitation, generate the token. Those happen in a browser while logged into your own Instagram and Facebook accounts.
So read the setup sections below carefully, because that part is yours. The code sections are for reference. Read them if you’re curious about what’s happening. Skip them if you’re not. Claude has them either way.
What You Need Before You Start
An Instagram account you control. It has to be a Business or Creator account. Personal accounts cannot post through the API at all. Switching is free and takes a minute, and I cover it below.
A Meta developer account. Free. You sign up at developers.facebook.com with a Facebook login.
Python and uv. uv is a fast Python package manager. It installs dependencies for you, so you never have to create a virtual environment by hand. Installation instructions for both macOS and Windows are at docs.astral.sh/uv/getting-started/installation/.
Claude Code. It comes in several forms, and any of them work here. There’s a desktop app for Mac and Windows, a command-line version you run in a terminal, extensions for VS Code and JetBrains, and a web version at claude.ai/code.
If you use the desktop app, you can skip the terminal entirely. Claude runs the commands for you and picks the right version for whichever machine you’re on, so the Mac/Windows split below is for reference, not a decision you have to make.
A terminal, if you’re using the command-line version. On a Mac, that’s Terminal, in Applications, then Utilities. On Windows, use PowerShell, not the old Command Prompt: click Start, type PowerShell, press Enter. Every terminal command in this post shows the Mac version first and the Windows version right under it. The Python files are identical on both.
Somewhere to put a file on the public internet. Instagram does not let you upload an image. You give Instagram a web address, and Instagram goes and fetches the image itself. So you need a place to host your images that anyone on the internet can reach over HTTPS. A file on your laptop, a link that expires in a few seconds, or anything behind a private VPN won’t work. There’s a free way to set this up in a few minutes with no server and no credit card, and I walk through it below.
Google Chrome. Meta’s developer dashboard uses pop-up windows that Safari’s privacy settings silently block. You click a button, nothing happens, and you get no error message explaining why. Do the browser steps in Chrome with your extensions turned off (or in incognito mode).
Why an MCP Server Is the Wrong Tool Here
Start with what MCP actually is, because the term gets thrown around loosely.
MCP stands for Model Context Protocol. It is a standard way to hand an AI model a set of tools it can discover and call on its own, in the middle of a conversation. When you connect Claude Code to a database through MCP, Claude can look at what tables exist, decide which one it needs, and query it without you spelling out each step. That is genuinely useful when the service is large, when the model needs to explore before acting, or when there is live state worth checking.
Instagram publishing is none of those things. It is three web requests that always happen in the same order. Create a draft, wait for Instagram to process it, publish it. There is nothing to explore and nothing to discover.
The second reason matters more, and it is the one worth remembering. An MCP tool is something the model decides to use. A script is something you decide to run. Posting to Instagram is public, it has your name on it, and taking it down afterward is awkward. You want a human approval step that exists by construction, not one that depends on the model choosing to ask you first.
So the shape that works is two pieces:
- A Python script that does the actual posting.
- A skill file that tells Claude Code when to run that script and what the rules are.
Claude reads your draft, runs the script in preview mode, shows you exactly what would go out, and posts only after you say yes. The approval step lives in the workflow, where you can see it.
How Instagram Publishing Actually Works
Before touching any settings, here is the mental model. Everything after this makes more sense once you have it.
Publishing one photo takes three requests to Instagram’s servers:
- Create a container. You send Instagram your caption and the web address of your image. Instagram creates a draft object and hands you back an ID. Nothing is public yet.
- Wait for it to finish. Instagram goes and downloads your image from the address you gave it. That takes a few seconds for a photo and considerably longer for a video. You check the container’s status until it says
FINISHED. - Publish it. You send the container ID back, and Instagram makes the post live.
The whole thing runs on the Meta Graph API, which is Meta’s name for the programming interface behind Facebook, Instagram, and Threads. “Graph” refers to how Meta models everything as objects connected to other objects. In practice, it means you send ordinary web requests to addresses like graph.instagram.com/v23.0/{your-user-id}/media and get JSON back.
Three Accounts To Have Before You Write Any Code
Each of these will stop a perfectly correct script. Clear them first so you are not debugging code that was never the problem.
Your account must be Business or Creator
Personal Instagram accounts cannot use the publishing API, and the error message doesn’t say that clearly. In the Instagram mobile app, go to Settings, then Account type and tools, then Switch to professional account. Pick Business or Creator, whichever fits. It is free, reversible, and doesn’t change how your profile looks to anyone.
Every post must carry a photo or a video
Instagram has no text-only posts. If you are building something that publishes to several platforms at once, Instagram is the one that makes the image mandatory rather than optional.
Video published through the API always goes out as a Reel. There is no setting to make it an in-feed video instead. Plan your aspect ratio with that in mind. Captions cap out at around 2,200 characters.
Instagram fetches your image. You do not upload it.
This changes how you build things. With most APIs, you attach the file to your request and send the bytes. Instagram does not work that way. You give it a public web address, and Instagram’s servers download the file from there.
That is why you need a public file host before your first successful post. Threads and Facebook publish the same way, so if you ever expand to those, the same host covers all three.
Hosting Your Images for Free
You need somewhere on the public internet to put an image file. No server, no credit card, and about five minutes. Here’s the way I’d send a beginner.
GitHub Pages
GitHub is where programmers store code, and it will also serve plain files on a real web address for free. You don’t need to know Git to use it this way.
One word you’ll see constantly there: a repository, or repo, is just a project folder that lives on GitHub. You’re going to make one, put pictures in it, and let GitHub hand them out on the web.
Rename your files before you upload them
Lowercase letters, numbers, and hyphens. No spaces, apostrophes, accents, or &. Rename the file on your computer before you drag it in.
Making the repository
- Make a free account at
github.com. - Click the + in the top right, then New repository. Name it
instagram-images. Set the visibility to Public. Then click Create repository. - On the Code tab, click Add file, then Upload files. Drag your images in and click Commit changes.
- Now click Settings at the top of the page, then Pages in the left sidebar. Under Branch, pick
mainand/ (root), then Save. - Wait about a minute. GitHub builds the site in the background.
Your image is now at https://YOURNAME.github.io/instagram-images/photo.jpg (swap in your username and filename). Use that address for image_url.
Three more things will make you think it’s broken when it isn’t.
The repository has to be Public. GitHub Pages on a private repository needs a paid plan and won’t turn on at all.
After any upload, the image address returns a 404 for up to a minute while GitHub rebuilds. Wait and refresh before you go debugging.
Visiting https://YOURNAME.github.io/instagram-images/ with no filename at the end also shows a 404, and that’s expected. There’s no home page here, only files. Always use the full address with the filename.
Do this by hand once, so you can see the whole thing work. After that, stop doing it by hand. The script later in this post uploads the image for you and builds the address itself, so posting becomes one command instead of a browser trip.
If you’d rather not make an account
Drag a folder of images onto app.netlify.com/drop, and Netlify gives you a public HTTPS address in about ten seconds, no account required to start. It’s the fastest way to get one post working. The trade-off is that you get a random address like zippy-pastry-4a91.netlify.app, and you need to make an account to keep the site or update it later.
Test the address before you blame the code
This is the single highest-value check in the whole post, because a bad image URL produces an error that points nowhere near the real problem.
Open your image address in a private or incognito window. You should see only the image on a blank background. No page around it, no download button, no sign-in prompt, no filename header.
If you see any of those, Instagram will choke, and your container will come back with status ERROR.
Then check the address bar itself. If you see any %20 in it, your filename has spaces. The picture will still load for you and still fail for Instagram, so this is the one problem the eye test above won’t catch. Rename and re-upload.
What does not work, and why people try it anyway
Google Drive, Dropbox, and iCloud share links all fail. They’re the first thing people reach for, and none of them work. When you share a file from those services, the address returns a web page that displays your image, not the image itself. A human sees a picture. Instagram’s servers see HTML and give up.
Also out: a file path on your own computer, anything on a home network or behind a VPN like Tailscale, and any link that expires. Instagram fetches from its own servers, somewhere else entirely, whenever it feels like it.
When you outgrow this, Cloudflare R2 and Amazon S3 are the next step. Both are cheap, and neither is complicated once you’ve done it once, but both want a credit card and make you learn bucket permissions before your first upload. That’s the wrong first step. Start with GitHub Pages, get a post live, graduate later.
Setting Up the Meta App
Step 1: Create the app
Go to developers.facebook.com, click My Apps, then Create App. Meta walks you through five screens: App details, Use cases, Business, Requirements, Overview.
App details. Give it a name and an email address you actually read, then click Next.
Do not put “Instagram”, “Facebook”, or “Meta” in the name. Those are Meta’s trademarks, and it strips them out silently, with no warning and no error. Name your app instagram-poster and you’ll find it later called poster. Pick something without them, like photo-publisher, and check the Overview screen at the end to confirm the name survived.
Use cases. This screen decides everything. Choose:
Manage messaging & content on Instagram Publish posts, share stories, respond to comments, answer direct messages and more with the Instagram API.
That’s the one. Click Next.
Three things on that screen will pull you the wrong way. Ignore every Marketing API option, because those are for running ads. Ignore Access the Threads API, which is a different product. And ignore Other, which drops you into Meta’s old app-type flow.
Business. Meta asks which business portfolio to connect. Choose “I don’t want to connect a business portfolio yet.” and click Next.
That option is there, and it’s the right one. A business portfolio is Meta’s container for apps and accounts, and connecting a verified one buys you two things: access to other companies’ data, and the ability to publish your app publicly. You want neither. Your app stays private and posts to your own account, so skipping costs you nothing and you can attach one later if you ever need it.
Requirements. This screen will say “No requirements identified.” Click Next. Requirements are the hoops Meta makes you jump through to access other people’s data or publish your app publicly, and you’re doing neither, so the list is empty. If it ever fills up later, that’s Meta reacting to something you added, not something you missed here.
Overview. A summary of the four screens before it. Check the app name here, because this is where a silently stripped name shows up. Then click Create app, which is the green button where Next used to be.
That’s the whole wizard: a name, an email, one use case, one skip, and two clicks. Nothing on those five screens costs money or requires approval.
Notice the use case you picked covers messaging and content. You only want the content half, so the permissions screen in the next step opens with messaging permissions you don’t need.
Step 2: Instagram setup screen
Two ways to reach it, same destination:
- On the Dashboard, click Customize the Manage messaging & content on Instagram use case.
- Or click Use cases in the left sidebar, then Customize.
You land on a page headed Customize use case, with Instagram API in a dropdown at the top left and a short menu under it:
- Permissions and features
- API setup with Instagram login
- API integration helper
- API setup with Facebook login
“API setup with Instagram login” is already selected, and that’s the one you want. You’ll also see your Instagram app ID and Instagram app secret on this page. You don’t need either one. The token you generate in a moment is all your script uses.
Step 3: Add the publishing permission
The first card reads “1. Add required messaging permissions”, and it lists three:
instagram_business_basicinstagram_business_manage_commentsinstagram_business_manage_messages
Look at what isn’t there. No instagram_business_content_publish. That’s the one that lets you post, and Meta doesn’t consider it required because the use case you picked covers messaging and content.
Click the big blue Add all required permissions button anyway, since instagram_business_basic is genuinely required. Then click Permissions and features in that left menu.
That opens a long table of every permission and feature Meta offers, mixed together and sorted in no useful order. Don’t scroll it. Press Cmd+F on a Mac or Ctrl+F on Windows, search the page for content_publish, and click the Add button on that row.
The other two required permissions, comments and messages, are for replying to DMs and comments. Harmless to leave in place, and neither posts anything.
Do this before connecting your account in the next step. If you connect first, the approval screen comes up without publishing on it, and the only fix is to remove the account and start again.
Step 4: Add yourself as a tester, then accept the invitation
Your app starts in Development mode, which means only approved test accounts can connect to it. Owning the Instagram account is not enough. You have to formally add it as a tester and then accept the invitation from the other side.
This is where the error “Insufficient Developer Role” comes from, and it gives you no hint about the fix.
You may not recognise it as an error. It arrives inside the popup as a bare black page with one line of plain text on it, at an address starting instagram.com/oauth/authorize/third_party/error/. No styling, no Instagram logo, no button. If the popup is narrow the first letter is cut off, so you read “nsufficient Developer Role” and wonder what broke.
There’s also a step before it that looks like a failure and isn’t. The first time you click Add account, the popup may just log you in and leave you sitting on your normal Instagram feed. That’s the login half of the flow finishing. Close it, click Add account again, and you’ll get either the consent screen or the error above.
Two parts, and skipping the second is the entire problem:
- In the left sidebar, scroll to the bottom. App roles sits in its own group below App settings, collapsed, with a chevron on the right. Expand it and click Roles. Then click the blue Add People button on the right.
In that dialog, scroll past the four obvious roles to the bottom, under a heading called “Additional roles for this app,” and pick “Instagram Tester.” Click Add and give it your Instagram handle.
There are two roles here with nearly the same name. “Tester” sits in the main list and “Instagram Tester” sits below the heading. Plain Tester is the one you’ll click by accident, it appears to work, and it does nothing for Instagram. If you’re still getting “Insufficient Developer Role” after adding yourself as a tester, this is why.
Instagram Tester’s description says it’s “required by the Instagram Basic Display API,” which is a product Meta has since deprecated. The description is stale. The role is still the right one.
- Open Chrome, sign in to that Instagram account, go to
https://www.instagram.com/accounts/manage_access/, and accept the pending invitation. The link in the notification email goes to the same page.
Meta takes five to ten minutes to propagate a role change. If Add account still fails right after you accept, wait before you start changing other settings. Many “this is broken” reports come from people reconfiguring during that window.
Getting Your Access Token
An access token is a long string of characters that proves your script can post to your account. Think of it as a password you can hand to a program, except it expires on a schedule and you can revoke it without changing your real password.
You get one from the Meta dashboard in a few clicks.
First, get back to the right page. Adding that permission left you on the Permissions and features table. Click API setup with Instagram login in the left menu to return. The Generate access tokens card is card 2 on that page, directly below the messaging permissions card.
On the Generate access tokens card, click Add account. Log in to Instagram in the pop-up and click Allow. Do this in Google Chrome.
Read that approval screen before you click. It should mention content publishing. If it does not, you connected the account before adding the permission in Step 3. Remove the account and redo it.
Then click Generate token next to your connected account. A dialog appears with the token and a Copy button. Copy it right then. Meta shows it exactly once, and there is no way to see it again. If you lose it, you generate a new one.
That string is your long-lived access token and it is valid for 60 days.
If clicking Generate token just drops you back on the dashboard with no dialog and no error, a popup blocker ate it. Switch to Chrome, disable your extensions, and allow popups for developers.facebook.com.
The second value: your Instagram user ID
You need one more thing for .env, and this is where a lot of people grab the wrong number.
Your Meta app has three different IDs, and only one of them belongs in your .env.
| What you’ll see | Where it lives | Is it what you need? |
|---|---|---|
| App ID | App settings, then Basic | No. That identifies your app |
| Instagram app ID | The API setup with Instagram login page | No. Also identifies your app |
| Instagram user ID | Under the connected account, after you add it | Yes |
Your script calls POST /{user_id}/media, and that user_id is your Instagram account, not your app. Put an app ID there and the very first request fails with an error that says nothing about which number was wrong.
This value doesn’t exist until the account is actually connected. If the Generate access tokens card doesn’t show a connected account yet, your tester invitation is still pending. Go back and finish that first, because there’s nothing to copy until it clears.
Once the tester invitation is accepted, the Generate access tokens card shows your Instagram account with a long number in blue directly beneath the handle. It looks like this:
<user-name>
<string-of-digits>
That number is your INSTAGRAM_USER_ID. Copy it from there.
Where to put the token
Never paste a token directly into your script, because scripts end up in git repositories and screenshots. Put it in a .env file instead, the plain text file of settings described at the top of this post. Each line is a name, an equals sign, and a value, and your script reads them when it starts.
Keeping it as a separate file buys you two things. You can share or publish your script without leaking anything, and when your token expires in 60 days you change one line in one file rather than hunting through code.
Make the folder. On a Mac in the terminal (just copy and paste these commands, one at a time):
mkdir -p ~/.config/instagram-poster
chmod 700 ~/.config/instagram-poster
On Windows, in PowerShell:
New-Item -ItemType Directory -Force -Path "$HOME\.config\instagram-poster"
Then create the .env file inside that folder with these two lines. You can use a text editor or Microsoft’s Visual Studio Code to edit the file:
INSTAGRAM_USER_ID=<the numeric user_id from above>
INSTAGRAM_ACCESS_TOKEN=<the 60-day token>
That file’s contents are the same on both systems. On a Mac, the path is ~/.config/instagram-poster/.env. On Windows, it’s C:\Users\YourName\.config\instagram-poster\.env.
To create and open it on Windows, run this rather than using File Explorer:
notepad "$HOME\.config\instagram-poster\.env"
Notepad offers to create the file, and because you gave it the full name, it saves as .env exactly. Do not make this file by right-clicking in File Explorer. Windows hides known extensions, so you end up with .env.txt. The name looks right on screen, and nothing can find your credentials.
Now lock it down so only you can read it. On a Mac in the terminal:
chmod 600 ~/.config/instagram-poster/.env
On Windows in PowerShell:
icacls "$HOME\.config\instagram-poster\.env" /inheritance:r /grant:r "$($env:USERNAME):(R,W)"
chmod 600 means only your own user account can read or write the file, and chmod 700 does the same for the folder. Windows has no chmod, so icacls does the equivalent: /inheritance:r removes the permissions the file inherited from its parent folders, and /grant:r then grants read and write to you alone.
Honest note for Windows users: files in your user profile are already unreadable by other ordinary accounts, so this step is belt-and-braces rather than a fix for something broken. Administrators on the machine can still read it either way. Keeping the file in your profile rather than in your project folder is what matters, because it means you cannot accidentally commit your token to git.
Your app ID and app secret are never needed at runtime. The token alone covers everything below, including renewing itself later.
The Whole Script
Here is the entire Python script, in one file, ready to run. It uploads your image to GitHub, waits for it to go live, posts it to Instagram, and can renew your token when needed.
Read it now or don’t. It is here so you have the finished article, not a pile of fragments to assemble. The explanations come after it.
Where it goes. Make a folder for this project using the terminal or PowerShell and save the file inside it as instagram_poster.py.
mkdir ~/instagram-poster
On Windows, in PowerShell:
New-Item -ItemType Directory -Force -Path "$HOME\instagram-poster"
You now have two locations, and the split is deliberate:
| Where | What’s in it |
|---|---|
~/instagram-poster/instagram_poster.py |
Your code. Safe to share, publish, or put on GitHub |
~/.config/instagram-poster/.env |
Your secrets. Never shared, never published |
# instagram_poster.py
import argparse
import base64
import os
import re
import time
import unicodedata
from pathlib import Path
import requests
from dotenv import load_dotenv
# ----------------------------------------------------------------- credentials
ENV_PATH = Path.home() / ".config" / "instagram-poster" / ".env"
load_dotenv(ENV_PATH)
IG_USER_ID = os.environ["INSTAGRAM_USER_ID"]
IG_TOKEN = os.environ["INSTAGRAM_ACCESS_TOKEN"]
GH_OWNER = os.environ["GITHUB_OWNER"]
GH_REPO = os.environ["GITHUB_REPO"]
GH_TOKEN = os.environ["GITHUB_TOKEN"]
IG_API = "https://graph.instagram.com/v23.0"
GH_API = "https://api.github.com"
# --------------------------------------------------------------- image hosting
def slugify(filename: str) -> str:
"""Turn 'Warm Fuzzygram - Julia.JPG' into 'warm-fuzzygram-julia.jpg'."""
stem, _, ext = filename.rpartition(".")
ascii_stem = unicodedata.normalize("NFKD", stem).encode("ascii", "ignore").decode()
slug = re.sub(r"[^A-Za-z0-9]+", "-", ascii_stem).strip("-").lower()
return f"{slug}.{ext.lower()}"
def upload_to_pages(local_path: Path) -> str:
"""Upload one image to your GitHub repo and return its public web address."""
name = slugify(local_path.name)
api = f"{GH_API}/repos/{GH_OWNER}/{GH_REPO}/contents/{name}"
headers = {"Authorization": f"Bearer {GH_TOKEN}", "Accept": "application/vnd.github+json"}
payload = {
"message": f"add {name}",
"content": base64.b64encode(local_path.read_bytes()).decode(),
}
# Replacing a file that already exists requires its current sha.
existing = requests.get(api, headers=headers, timeout=30)
if existing.status_code == 200:
payload["sha"] = existing.json()["sha"]
requests.put(api, headers=headers, json=payload, timeout=60).raise_for_status()
# Wait for GitHub Pages to publish it before handing the address to Instagram.
url = f"https://{GH_OWNER}.github.io/{GH_REPO}/{name}"
for _ in range(30):
if requests.head(url, timeout=30).status_code == 200:
return url
time.sleep(4)
raise RuntimeError(f"GitHub Pages did not publish {name} within two minutes")
# ------------------------------------------------------------------- instagram
def wait_for_container(creation_id: str, tries: int = 60, delay: int = 5) -> None:
"""Ask Instagram every few seconds whether it has finished with your media."""
for _ in range(tries):
status = requests.get(
f"{IG_API}/{creation_id}",
params={"fields": "status_code", "access_token": IG_TOKEN},
timeout=30,
).json().get("status_code")
if status == "FINISHED":
return
if status in {"ERROR", "EXPIRED"}:
raise RuntimeError(f"Instagram container {status}: check the media address and format")
time.sleep(delay)
raise RuntimeError("Instagram did not finish processing in time")
def post_image(caption: str, image_url: str) -> str:
"""Create a container, wait for it, publish it. Returns the media id."""
create = requests.post(
f"{IG_API}/{IG_USER_ID}/media",
params={"image_url": image_url, "caption": caption, "access_token": IG_TOKEN},
timeout=30,
)
create.raise_for_status()
creation_id = create.json()["id"]
wait_for_container(creation_id)
publish = requests.post(
f"{IG_API}/{IG_USER_ID}/media_publish",
params={"creation_id": creation_id, "access_token": IG_TOKEN},
timeout=30,
)
publish.raise_for_status()
return publish.json()["id"]
def post_video(caption: str, video_url: str) -> str:
"""The same three steps. Instagram publishes API video as a Reel."""
create = requests.post(
f"{IG_API}/{IG_USER_ID}/media",
params={
"media_type": "REELS",
"video_url": video_url,
"caption": caption,
"access_token": IG_TOKEN,
},
timeout=30,
)
create.raise_for_status()
creation_id = create.json()["id"]
wait_for_container(creation_id)
publish = requests.post(
f"{IG_API}/{IG_USER_ID}/media_publish",
params={"creation_id": creation_id, "access_token": IG_TOKEN},
timeout=30,
)
publish.raise_for_status()
return publish.json()["id"]
def _save_to_env(key: str, value: str) -> None:
"""Replace one line in the .env file, or add it if it isn't there yet."""
lines = ENV_PATH.read_text().splitlines()
updated, found = [], False
for line in lines:
if line.startswith(f"{key}="):
updated.append(f"{key}={value}")
found = True
else:
updated.append(line)
if not found:
updated.append(f"{key}={value}")
ENV_PATH.write_text("\n".join(updated) + "\n")
def ensure_fresh_token() -> None:
"""Renew the Instagram token when it is within a week of expiring."""
global IG_TOKEN
expires_at = int(os.environ.get("INSTAGRAM_TOKEN_EXPIRES_AT", "0"))
if expires_at and time.time() < expires_at - 7 * 86400:
return # still weeks of life left, nothing to do
resp = requests.get(
"https://graph.instagram.com/refresh_access_token",
params={"grant_type": "ig_refresh_token", "access_token": IG_TOKEN},
timeout=30,
)
if resp.status_code != 200:
# A token under 24 hours old cannot be refreshed yet. Record when this
# one is due so the check works from the next run onward.
if not expires_at:
_save_to_env("INSTAGRAM_TOKEN_EXPIRES_AT", str(int(time.time()) + 60 * 86400))
return
data = resp.json()
IG_TOKEN = data["access_token"]
_save_to_env("INSTAGRAM_ACCESS_TOKEN", IG_TOKEN)
_save_to_env("INSTAGRAM_TOKEN_EXPIRES_AT", str(int(time.time()) + int(data["expires_in"])))
print(f"Token renewed. Good for another {int(data['expires_in']) // 86400} days.")
# ---------------------------------------------------------------------- run it
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Post an image to Instagram.")
parser.add_argument("--image", required=True, help="Path to the picture on your computer")
parser.add_argument("--caption", required=True, help="The text under the post")
parser.add_argument("--dry-run", action="store_true", help="Upload and show, but post nothing")
args = parser.parse_args()
image = Path(args.image).expanduser()
if not image.is_file():
raise SystemExit(f"No such file: {image}")
if len(args.caption) > 2200:
raise SystemExit(f"Caption is {len(args.caption)} characters. Instagram allows 2200.")
ensure_fresh_token()
url = upload_to_pages(image)
print("Image is live at:", url)
print(f"Caption ({len(args.caption)} of 2200 characters):")
print(args.caption)
if args.dry_run:
print("\nDry run. Nothing was posted to Instagram.")
raise SystemExit(0)
media_id = post_image(args.caption, url)
print("\nPosted. Media id:", media_id)
One More Credential: The GitHub Token
The script uploads to GitHub on your behalf, so it needs permission. This is a second credential, unrelated to your Instagram one.
At github.com, click your profile icon in the top right, then Settings, then Developer settings in the left-side panel, then Personal access tokens, then Fine-grained tokens. Click Generate new token.
On that screen:
- Title it
instagram-images. - Set the expiration to No expiration.
- Under Repository access, choose Only select repositories, and pick your
instagram-imagesrepository. - Under Permissions, click + Add permissions. A searchable list opens. Find Contents and select it.
- Now that Contents is in the list, use the dropdown beside it to choose Read and write.
- Click Generate token and copy it.
Step 4 is a step, not a description. The permissions box starts empty and says “No repository permissions added yet.” You have to add Contents before you can set its access level, and they’re separate clicks. If you’re looking for a Read and write option and can’t find one, it’s because nothing has been added to the list yet.
A second permission appears automatically, and that’s normal. The moment you add Contents, GitHub adds Metadata: Read-only underneath it, tagged Required. You didn’t do that, and you can’t undo it. Notice it has no × beside it like Contents does, because GitHub won’t let you remove it. It grants nothing more than reading basic facts about the repository, like its name and whether it exists, which the API has to check before it can write a file. Leave it alone.
So your finished token reads: Contents, Access: Read and write and Metadata, Access: Read-only, on one repository. That’s the correct end state. Click Generate token and copy the whole token right away, because GitHub shows it only once. Never put it in Claude Code.
Add three lines to the same .env file you made earlier, at ~/.config/instagram-poster/.env.
GITHUB_TOKEN=<the fine-grained token>
GITHUB_OWNER=<your github username>
GITHUB_REPO=<github repository name>
That file now holds five lines: the two Instagram values from before, and these three.
Scoping the token to one repository matters. A credential that can only write pictures into a public folder of pictures is one you don’t have to lose sleep over.
Running It
You don’t edit the script to post. You pass it the picture and the caption:
cd ~/instagram-poster
uv run --with requests --with python-dotenv instagram_poster.py \
--image ~/Desktop/beach.jpg \
--caption "Sunrise over Camelback. Worth the 5am alarm."
The backslashes at the end of those lines are line continuations, letting one long command span three lines. Type it all on one line and drop them if you prefer.
instagram_poster.py never changes. Every post is the same command with different arguments. If you ever find yourself opening the script to post something, something has gone wrong.
uv run fetches requests and python-dotenv into a temporary environment and runs your file with them available. Nothing is installed permanently, and there’s no virtual environment to activate or remember.
Expect it to sit there quietly for a while first. The script waits twice, once for GitHub Pages to publish your image and once for Instagram to download it. Thirty seconds to two minutes is normal, and nothing prints while it waits. That silence is the script working, not hanging.
Then two lines appear:
Image is live at: https://YOURNAME.github.io/instagram-images/beach.jpg
Posted. Media id: 17924418362091845
The first line is your image on the web. Click it, and you should see the picture on a blank background and nothing else.
The second is Instagram’s ID for the post it just made. You’ll rarely need it, but it is proof the publish step actually finished rather than quietly failing.
Open Instagram and the post is there.
If something fails, the message names the step. An upload problem points to GitHub, a container ERROR points to the image address, and a permissions message points to your token. The “When It Fails” table further down covers the specific ones.
Reading the Script (optional reading only if you are interested)
You don’t need any of this to use the script. It’s here for when something breaks, or when you want to change how it works.
The credentials block
load_dotenv(Path.home() / ".config" / "instagram-poster" / ".env")
IG_TOKEN = os.environ["INSTAGRAM_ACCESS_TOKEN"]
load_dotenv opens your .env and loads every line into the running program. That’s the moment your token goes from a file on disk into the script, and it’s why no password appears anywhere in the code.
os.environ["..."] then pulls one value back out. Written with square brackets, a missing value stops the script immediately and names the thing it couldn’t find. The alternative spelling fails silently and surfaces later as a baffling Instagram error, so this is deliberate.
Uploading the image
slugify strips accents, lowercases everything, and turns every run of spaces and punctuation into a single hyphen. That permanently removes the filename problem from earlier. Name your files whatever you like on your own computer.
upload_to_pages sends the picture to GitHub with PUT /repos/{owner}/{repo}/contents/{name}. The file is base64-encoded because binary data has to be wrapped up as text to travel inside JSON.
Two details there are worth knowing, because both produce errors that point somewhere else:
The sha lookup. GitHub lets you create a new file freely, but replacing an existing one requires sending that file’s current sha as proof you knew it was there. Leave it out and you get a 422 whose wording doesn’t obviously mean “this file already exists.”
The polling loop at the end. GitHub Pages takes 30 to 60 seconds to rebuild after an upload. Hand Instagram the address before that finishes and Instagram fetches a 404, your container comes back ERROR, and you go hunting for a bug in the posting code. Thirty tries at four seconds gives it two minutes.
Posting to Instagram
post_image is the three steps from the mental model earlier. POST /{user_id}/media creates the container and hands back a creation id, which is a different number from the media id you get at the very end. wait_for_container polls until the status reads FINISHED. media_publish makes it live.
status_code comes back as IN_PROGRESS, FINISHED, ERROR, or EXPIRED, and only FINISHED can be published. ERROR nearly always means Instagram couldn’t download or couldn’t read your image, which is a hosting problem rather than a code one.
Everything travels as params rather than a JSON body, which is the Graph API’s convention. It means the requests library encodes your caption’s spaces and punctuation for you, so don’t encode it yourself.
post_video is the same function with media_type set to REELS and video_url in place of image_url. That’s the only video option the API offers, so anything you publish as video appears as a Reel. Photos finish processing in seconds and Reels take considerably longer, which is why the timeout runs to five minutes.
The bottom block
if __name__ == "__main__":
Everything above this line only describes what’s possible. This block is what actually runs, and it’s where argparse reads the --image, --caption and --dry-run values off your command. It checks two things before doing any work: that the file exists, and that the caption fits in 2,200 characters. Both fail fast with a plain message rather than surfacing later as a confusing Instagram error.
The first thing it does is call ensure_fresh_token(), which is covered next.
Keeping the Token Alive
You don’t have to do anything. The script handles it.
Your Instagram token expires 60 days after you create it. That’s the part of this setup most likely to break months from now, long after you’ve forgotten how any of it works, so the script renews the token itself rather than leaving it to you to remember.
Every run starts with ensure_fresh_token(). It checks how much life the token has left. If there’s more than a week, it does nothing and moves on. If expiry is close, it asks Instagram for a new token, writes it straight back into your .env, and carries on posting. You’ll see one extra line when that happens:
Token renewed. Good for another 60 days.
That’s the whole maintenance story. Post something at least once every couple of months and the token rolls forward on its own, indefinitely.
How it knows. The script keeps a sixth line in your .env called INSTAGRAM_TOKEN_EXPIRES_AT, which is the renewal date written as a number. It adds that line itself the first time it runs, so you never type it.
Why it waits until the last week. Instagram refuses to refresh a token less than 24 hours old. Renewing on every single post would hit that rule constantly and fail. Checking once and only acting near the deadline sidesteps it entirely, which is why the script tends to do nothing.
The one case that still needs you. If a token expires completely, there’s no refresh path. That only happens if you don’t post for over two months. The fix is to generate a new token in the Meta dashboard exactly as you did the first time, and paste it into .env. The script picks up from there.
Wiring It Into Claude Code
The script is the hard part, and it is done. The Claude Code side is one markdown file.
Where it goes. Skills live in a .claude/skills/ folder, each in its own subfolder, in a file named SKILL.md. Put yours in your home folder so it works everywhere:
~/.claude/skills/instagram-post/SKILL.md
Make that folder from the terminal on a Mac:
mkdir -p ~/.claude/skills/instagram-post
On Windows, in PowerShell:
New-Item -ItemType Directory -Force -Path "$HOME\.claude\skills\instagram-post"
Here is the whole file. Copy this into a file named SKILL.md, and place it in the instagram-post folder.
---
name: instagram-post
description: Post an image to Instagram using instagram_poster.py. Use when
the user asks to post something to Instagram, publish a photo, or share an
image on Instagram.
---
# Posting to Instagram
The script lives at `~/instagram-poster/instagram_poster.py`. It uploads the
image to GitHub Pages, waits for it to go live, then posts it to Instagram.
Every command below runs from that folder, so `cd` there first.
## The rules, in order
1. **Never post without a dry run first.** Fill in the image path and caption
I gave you, and run:
`cd ~/instagram-poster && uv run --with requests --with python-dotenv instagram_poster.py --image "<path>" --caption "<caption>" --dry-run`
That uploads the image and prints the caption, but posts nothing.
2. **Show me the dry run output and stop.** Print the image URL and the full
caption exactly as they came back. Then wait. Do not continue on your own.
3. **Only post after I say yes in this conversation.** "Looks good", "post it",
"go ahead". Silence is not approval. Neither is me approving a different
post earlier.
4. **To post for real, run the identical command without `--dry-run`.**
Same image, same caption, nothing retyped or changed.
5. **Never edit my caption.** If it exceeds 2,200 characters, tell me and stop.
Do not shorten it yourself.
6. **One post per approval.** If I want another, we start again at step 1.
## Never edit the script
The image and caption are arguments. `instagram_poster.py` does not change to
post something new. If you are opening it, stop.
## When it fails
A container `ERROR` almost always means the image URL is unreachable or has
spaces in the filename. Open the URL and look before you change anything.
Read rule 3 again, because it is the whole point. You are writing down, in plain English, in a file you own, that this process stops and waits for a human. It’s the same instruction every time. It doesn’t depend on the model being in a cautious mood, and you can open it in a year and see exactly what you told it.
Notice what isn’t in that file: any explanation of the Instagram API, the container flow, or the token. Claude doesn’t need any of it, because the script already contains it. A skill file that re-explains your code is a skill file that goes stale the first time you change the code.
Using it. Open Claude Code anywhere, in any folder, and say:
Post ~/Desktop/beach.jpg to Instagram with the caption
"First post from a script."
Claude reads the skill, runs the dry run, shows you the image URL and caption, and stops. You look at the URL, confirm it’s your picture, and say “post it.” Then it posts.
That pause is the entire argument for building it this way.
What This Looks Like Day to Day
Everything above is setup you do once. Here is the loop you’ll actually live in, start to finish, for every post after that.
You have a photo you want to post. It’s sitting on your desktop. It’s called whatever your camera called it, with spaces and capital letters, and you don’t care because the script fixes that.
1. Open Claude Code. Anywhere. The skill lives in your home folder, so it doesn’t matter what you’re working on.
claude
2. Ask for what you want, in a normal sentence.
Post ~/Desktop/IMG_4823.jpg to Instagram. Caption:
"Sunrise over Camelback. Worth the 5am alarm."
3. Claude runs the dry run and stops. You did not ask it to. The skill file did.
Image is live at: https://YOURNAME.github.io/instagram-images/img-4823.jpg
Caption (48 of 2200 characters):
Sunrise over Camelback. Worth the 5am alarm.
Dry run. Nothing was posted to Instagram.
Notice IMG_4823.jpg came back as img-4823.jpg. That’s slugify doing its job.
4. Check the link. Click it. You should see your photo on a blank background. This takes three seconds and catches the single most common failure before it happens.
5. Say the word.
Looks good, post it.
6. Claude posts it.
Posted. Media id: 17924418362091845
Open Instagram. It’s there.
That’s the whole thing: two sentences from you, one link to eyeball, about ninety seconds of waiting. No dashboard, no dragging files into a browser, no copying URLs.
What you are not doing is worth listing, because every one of these was a manual step before you built this:
You aren’t renaming the file. You aren’t uploading it anywhere by hand. You aren’t finding or copying its web address. You aren’t checking whether your token expired. And you aren’t hoping the model doesn’t post something before you’ve seen it, because the skill file makes that pause structural rather than optional.
Without Claude Code, the same loop is two commands. Run the script with your image, caption and --dry-run, check the link, then run the identical command without the flag. Same gate, more typing.
When something breaks, the error names its step, and the table below maps the common ones to their real cause. The overwhelmingly likely culprit is the image address, which is exactly what step 4 exists to catch.
When It Fails
| What you see | What is actually wrong |
|---|---|
| The Add account popup just shows your Instagram feed | That was the login step. Close it and click Add account again |
| Still “Insufficient developer role” after adding a tester | You picked Tester, not Instagram Tester. They are different roles in the same dialog |
| Your handle shows an orange Pending badge in the roles table | The invitation is not accepted yet. Accept it at instagram.com/accounts/manage_access/, then confirm the badge clears |
| “Insufficient developer role” on Add account | You did not accept the tester invitation, or you accepted it less than ten minutes ago |
| Generate token does nothing, no dialog, no error | A popup blocker ate it. Use Chrome with extensions off |
| The approval screen has no publishing option | You connected the account before adding the permission. Remove it and redo |
Container status comes back as ERROR |
Instagram could not download or read your image. Open the URL in a private window: you should see the bare image and nothing else |
| Your image URL is a Google Drive, Dropbox, or iCloud share link | Those return a web page that displays the image, not the image itself. Use GitHub Pages instead |
The image URL opens fine in your browser but the container still says ERROR |
Check the filename for spaces. %20 in the address works in a browser and trips Instagram. Rename the file to lowercase-with-hyphens and re-upload |
| Publish fails right after the container was created fine | You skipped the waiting step |
| Refresh fails on a token you just made | The token is under 24 hours old. Wait a day |
| Your first post fails and the error names no cause | Check INSTAGRAM_USER_ID. App ID, Instagram app ID and Instagram user ID are three different numbers, and only the last belongs in .env |
| “GitHub Pages is currently disabled. You must first add content to your repository” | The repository is empty, so there’s no branch to publish. Upload an image first, then go back to Pages |
| Your GitHub Pages address 404s with no filename on the end | Expected. There’s no home page, only files. Use the full address including the filename |
| Your Pages URL is a domain you didn’t choose | Your GitHub user site has a custom domain, and every project site inherits it. Remove it in the USERNAME.github.io repo, not this one |
422 when uploading an image through the API |
The file already exists. Fetch its sha first and include it in the request |
Container ERROR on a freshly uploaded image |
You handed Instagram the URL before Pages finished rebuilding. Poll the URL until it returns 200 first |
| Windows: your script cannot find the token | Your file saved as .env.txt. File Explorer hides the extension. Recreate it with the notepad command shown above |
| Windows: PowerShell errors on a pasted script block | The closing '@ must be at the very start of its own line, with no spaces before it |
SyntaxWarning: invalid escape sequence then FileNotFoundError |
You dragged the file into the terminal and pasted a shell-escaped path. Delete every backslash |
| You can’t find “Instagram” in the Meta app sidebar | New apps don’t have one. Go to Use cases, then Customize |
| You clicked “Add all required permissions” but still can’t post | That button omits instagram_business_content_publish. Add it by hand under Permissions and features |
| Your Meta app has a shorter name than you typed | Meta silently strips its own trademarks. Rename it in App settings without “Instagram”, “Facebook” or “Meta” in it |
| No Read and write option on the GitHub token screen | The permissions list starts empty. Click + Add permissions and add Contents first, then set its access level |
403 or 404 when the script uploads an image |
The token is missing Contents write access, or it’s scoped to the wrong repository |
Quick Reference
| Thing | What it does |
|---|---|
graph.instagram.com/v23.0 |
The main API address for the Instagram Login setup |
POST /{user_id}/media |
Creates an unpublished container |
GET /{creation_id}?fields=status_code |
Checks whether the container is ready |
POST /{user_id}/media_publish |
Makes the post live |
GET /refresh_access_token |
Renews the 60-day token |
instagram_business_basic |
Permission required for every call |
instagram_business_content_publish |
Permission required to post |
media_type=REELS |
The only video format the API supports |
| Caption limit | About 2,200 characters |
| Token lifetime | 60 days, renewed by the script automatically |
--dry-run |
Uploads and shows the caption, posts nothing |
.claude/skills/<name>/SKILL.md |
Where a Claude Code skill file lives |
PUT /repos/{owner}/{repo}/contents/{path} |
Uploads an image to your GitHub Pages repo |
| GitHub fine-grained token | Contents: Read and write, scoped to the image repo only |
What Else This Connection Can Do
Everything above is about publishing, but the token you just built is broader than that. The obvious question is whether it can read your Instagram feed. The answer is a useful no.
| What | Can it? | Why |
|---|---|---|
| Your own posts | Yes | GET /me/media returns your captions, permalinks, timestamps, and like and comment counts |
| Comments on your posts | Yes | You already have instagram_business_manage_comments |
| Your DMs | Yes | You already have instagram_business_manage_messages |
| Your home feed | No | Instagram has no API for the timeline. It does not exist to build against |
| Other people’s posts | Essentially no | Only limited business discovery and hashtag search, and only on the Facebook Login path this post avoids |
The reframe worth keeping: this connection reads your own account, not Instagram at large. Meta closed the read-my-feed door years ago and no permission reopens it. If you came here hoping to build something that watches other accounts, this is not the tool.
But those two messaging permissions are not wasted. Remember the blue Add all required permissions button that gave you comments and messages alongside the basics? With the token you already have, and no further setup, you can ask Claude Code to pull the unanswered comments on your recent posts and draft replies for you to approve. Same pattern as posting: the script fetches, Claude drafts, you decide.
That is a different post. The point here is that the credential you just spent an hour building is worth more than the one job you built it for.
What App Review Costs You, and When You Need It
Everything above works with your app sitting in Development mode, as long as the account you are posting to is an accepted tester. If you are building this for yourself, that is the whole story and you never submit anything to Meta for review.
App Review becomes mandatory the moment you want to publish on behalf of accounts you do not own, which is any tool you hand to other people. That’s also when the business portfolio you skipped during setup comes back, because publishing an app publicly requires a verified one. Budget two to four weeks, and expect to record a screen capture walking a reviewer through your publishing flow from start to finish.
Build it and use it privately first. The review goes considerably better when you are demonstrating something that already works than when you are describing something you plan to build.