---
title: "Claude Code social media automation: a skill that posts to Instagram"
description: "Have Claude Code draft and publish Instagram posts with a skill, a script and Meta's official API, asking you before every publish. No third-party server."
url: https://rizamo.com/guides/claude-code-social-media-automation/
---

# Claude Code social media automation: a skill that posts to Instagram

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](/guides/how-to-post-to-instagram-via-api/), 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

1. Get an Instagram token and account id, and export them in the shell you start Claude Code from.
2. Add `publish.sh`, a script that creates the container, waits until it is ready and publishes it.
3. Add a skill, `/instagram-post`, that drafts the caption, shows it to you and runs the script.
4. Add an `ask` rule so the script never runs without your approval.
5. Type `/instagram-post` with 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](https://code.claude.com/docs/en/skills)). An MCP server is added with `claude mcp add`, and a remote one signs in through `/mcp` ([MCP](https://code.claude.com/docs/en/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](https://developers.facebook.com/documentation/instagram-platform/overview).
- A Meta app with Instagram Login and a token with `instagram_business_basic` and `instagram_business_content_publish`. For your own account, the **Generate token** button in the App Dashboard gives a 60-day token ([get started](https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-instagram-login/get-started)).
- The account's `user_id` from `GET /me?fields=user_id,username`, as in the [Instagram API guide](/guides/how-to-post-to-instagram-via-api/#post-an-image-with-curl).
- `curl` and `jq` 1.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](https://developers.facebook.com/documentation/instagram-platform/content-publishing) 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`:

```bash title="In your shell, not in a file you commit"
export IG_TOKEN="<INSTAGRAM_USER_ACCESS_TOKEN>"
export IG_ID="<USER_ID>"
claude
```

Anything 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](https://developers.facebook.com/documentation/instagram-platform/content-publishing): 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.

```bash title=".claude/skills/instagram-post/publish.sh"
#!/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 `ERROR` and `EXPIRED`** and prints the container's `status`, which holds the error subcode ([IG Container](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/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 a `config_issue` such as `CAPTION_NOT_ATTACHED`: the post went out without its caption, so do not publish it again ([media publish](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/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](https://code.claude.com/docs/en/skills)).

```text title=".claude/skills/instagram-post/SKILL.md"
---
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](https://code.claude.com/docs/en/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.

```json title=".claude/settings.json"
{
  "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

```bash title="In Claude Code"
/instagram-post https://example.com/photos/autumn-menu.jpg the new autumn menu, three drinks, warm tone
```

Claude 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](/guides/how-to-post-to-instagram-via-api/#post-a-reel).

## 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](https://code.claude.com/docs/en/headless)). 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](https://developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/content_publishing_limit)). And the token from **Generate token** lasts 60 days: refresh it before then ([Business Login for Instagram](https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-instagram-login/business-login)), or the script starts failing with code `190` ([Graph API error handling](https://developers.facebook.com/docs/graph-api/guides/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](https://developers.facebook.com/documentation/instagram-platform/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](/agents/claude-code/)). Rizamo is opening soon: [join the waitlist](/#waitlist).
