Claude Code can write a post, but it cannot publish one until something gives it a way into the network. Most pages on this topic answer with one product's MCP server. This guide builds the other answer: a project skill and a short script that call Meta's official Instagram API, with Claude Code asking you before anything goes live.
The API calls are the ones from our Instagram API guide, where each call was run on a real account. Read that one for the details of each call; this one is about wiring them into Claude Code safely.
The short version
- Get an Instagram token and account id, and export them in the shell you start Claude Code from.
- Add
publish.sh, a script that creates the container, waits until it is ready and publishes it. - Add a skill,
/instagram-post, that drafts the caption, shows it to you and runs the script. - Add an
askrule so the script never runs without your approval. - Type
/instagram-postwith a media URL and what the post is about.
Three ways to give Claude Code a network
| Way | What it is | Better when |
|---|---|---|
| A skill and a script, this guide | Instructions plus a script that calls the network's own API | You post to your own accounts on one or two networks and want no extra service |
| A hosted MCP server | A provider's server, added with claude mcp add, with tools to publish and schedule |
You post to many networks, or to accounts other people own |
| Your own MCP server | A server you write over the network's API | Several agents or apps share the same publishing code |
A skill is a folder with a SKILL.md file. Project skills live in .claude/skills/<name>/ and are shared through git; personal ones live in ~/.claude/skills/ (skills). An MCP server is added with claude mcp add, and a remote one signs in through /mcp (MCP).
Skills that only plan or write content, such as content calendars and caption writers, are common. They do not publish: something still has to call the network.
Before you start
- An Instagram professional account, business or creator. Meta's API only serves professional accounts.
- A Meta app with Instagram Login and a token with
instagram_business_basicandinstagram_business_content_publish. For your own account, the Generate token button in the App Dashboard gives a 60-day token (get started). - The account's
user_idfromGET /me?fields=user_id,username, as in the Instagram API guide. curlandjq1.6 or later.- The image or video at a public URL. Meta fetches the file itself, so it must be on a publicly accessible server when the script runs. A file on your laptop will not work.
Keep the token out of the repository. Export it in your shell before you run claude:
export IG_TOKEN="<INSTAGRAM_USER_ACCESS_TOKEN>"
export IG_ID="<USER_ID>"
claudeAnything the agent's shell can read, the agent can read. A token with only the two permissions above can read the account's profile and publish to it, nothing else.
The publish script
The script does the three calls of Meta's content publishing flow: create a container, wait until its status_code is FINISHED, publish it. The waiting matters: every container is processed in the background, images included, and Meta recommends checking once a minute for no more than five minutes.
#!/usr/bin/env bash
# Usage: publish.sh <image|reel> <public media URL> <caption file>
set -euo pipefail
: "${IG_TOKEN:?export IG_TOKEN first}" "${IG_ID:?export IG_ID first}"
KIND="$1"; MEDIA_URL="$2"; CAPTION_FILE="$3"
API="https://graph.instagram.com/v26.0"
case "$KIND" in
image) BODY=$(jq -n --arg url "$MEDIA_URL" --rawfile c "$CAPTION_FILE" \
'{image_url: $url, caption: ($c | rtrimstr("\n"))}') ;;
reel) BODY=$(jq -n --arg url "$MEDIA_URL" --rawfile c "$CAPTION_FILE" \
'{media_type: "REELS", video_url: $url, caption: ($c | rtrimstr("\n")), share_to_feed: true}') ;;
*) echo "The first argument must be image or reel" >&2; exit 2 ;;
esac
# 1. Create the container
CREATED=$(curl -s -X POST "$API/$IG_ID/media" \
-H "Authorization: Bearer $IG_TOKEN" \
-H "Content-Type: application/json" -d "$BODY")
CONTAINER_ID=$(jq -r '.id // empty' <<<"$CREATED")
[ -n "$CONTAINER_ID" ] || { echo "$CREATED" >&2; exit 1; }
status() {
curl -s "$API/$CONTAINER_ID?fields=status_code,status&access_token=$IG_TOKEN"
}
# 2. Wait for FINISHED: a first look after 5 seconds, then once a minute, 5 checks at most
sleep 5
for i in 1 2 3 4 5; do
STATUS=$(status | jq -r .status_code)
case "$STATUS" in
FINISHED) break ;;
ERROR|EXPIRED) status >&2; exit 1 ;;
esac
[ "$i" -lt 5 ] && sleep 60
done
[ "$STATUS" = "FINISHED" ] || { echo "Container $CONTAINER_ID is still $STATUS" >&2; exit 1; }
# 3. Publish. If this call fails or times out, read the container before trying again.
if ! PUBLISHED=$(curl -s --fail-with-body --max-time 60 -X POST "$API/$IG_ID/media_publish" \
-H "Authorization: Bearer $IG_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"creation_id\": \"$CONTAINER_ID\"}"); then
echo "Publish failed or timed out. Container status:" >&2
status >&2
exit 1
fi
echo "$PUBLISHED"Three choices in it come from Meta's docs:
- The caption goes through
jq, so quotes, emojis and line breaks reach Meta as valid JSON. - The script stops on
ERRORandEXPIREDand prints the container'sstatus, which holds the error subcode (IG Container). - It never publishes twice on its own. A publish call that timed out may still have published. If the container reads
PUBLISHED, the post is live. A success can also carry aconfig_issuesuch asCAPTION_NOT_ATTACHED: the post went out without its caption, so do not publish it again (media publish).
The skill
disable-model-invocation: true means Claude never starts the skill by itself: it runs when you type /instagram-post. $ARGUMENTS is replaced by what you type after the name (skills).
---
name: instagram-post
description: Draft and publish one image or reel to our Instagram account through Meta's API.
disable-model-invocation: true
argument-hint: "<public image or video URL> <what the post is about>"
---
Publish one Instagram post from: $ARGUMENTS
1. The URL must be public and point to a JPEG image, or an MP4 or MOV video for a reel.
If it is a local path or behind a login, stop and say so.
2. Write the caption: at most 2,200 characters, 30 hashtags and 20 @ tags.
Follow the voice in .claude/skills/instagram-post/voice.md if it exists.
3. Show me the caption and whether it is an image or a reel. Wait for me to say yes.
If I ask for changes, show the new version and wait again.
4. Save the approved caption to /tmp/ig-caption.txt and run, from the project root:
bash .claude/skills/instagram-post/publish.sh <image|reel> <URL> /tmp/ig-caption.txt
5. Reply with the id the script prints. If it fails, show Meta's error code and subcode
and what they mean. Never run the script a second time for the same post without asking me.A voice.md next to it, with a few past captions and the words you never use, does more for the drafts than a longer prompt.
Ask before every publish
The skill tells Claude to wait for your yes, and the permission system makes sure of it. An ask rule prompts every time the command runs, and it wins over any allow rule that also matches, because Claude Code checks deny, then ask, then allow (permissions). So a broad rule someone adds later, such as allowing every bash command, does not turn publishing into something that runs without you, and the rule still prompts in auto mode (same page). Keep bypass mode, which skips permission checks, out of a project that can publish.
{
"permissions": {
"ask": ["Bash(bash .claude/skills/instagram-post/publish.sh *)"]
}
}Commit the skill, the script and this file together, and everyone on the project gets the same safeguard.
Run it
/instagram-post https://example.com/photos/autumn-menu.jpg the new autumn menu, three drinks, warm toneClaude shows the caption, you answer, Claude asks to run publish.sh, you approve, and it replies with the post id. An image takes seconds. A short reel took about a minute in our test.
Running it on a schedule
claude -p runs Claude Code without the interactive screen, and user-invoked skills work in it: put /instagram-post in the prompt (headless mode). In a -p run, a call that needs approval is denied when nobody can approve it, so the ask rule above stops the script, as it should.
To publish with nobody watching, you would remove that rule and allow the script, and Claude would publish captions nobody read. A safer split is to schedule the drafting and keep the publishing yours: a scheduled claude -p writes next week's captions to files, and you publish each one with /instagram-post after reading it.
Two limits apply however you run it. Each account can publish a limited number of posts through the API in 24 hours, so read content_publishing_limit instead of guessing (content publishing limit). And the token from Generate token lasts 60 days: refresh it before then (Business Login for Instagram), or the script starts failing with code 190 (Graph API error handling).
When a skill alone is not enough
The skill is the right size for one person or one team posting to its own Instagram account. It stops fitting when:
- You post to many networks. Each one is another script, another login and another set of rules.
- You post for other people. Their accounts need your app to pass Meta's App Review and Business Verification (App Review), plus a login flow and a token per account.
- Your media is not already public. Something has to host each file at a public URL until Meta fetches it.
- Several agents publish. A script per project drifts; a shared MCP server or API keeps one copy of the rules.
Those are the cases where a hosted MCP server earns its place.
Where Rizamo fits
Rizamo is a social media API and MCP server built to take that layer off your hands: one call to publish, with Rizamo creating the container, waiting for it, publishing it and refreshing the tokens. In Claude Code it is added as an MCP server (Claude Code on Rizamo). Rizamo is opening soon: join the waitlist.