| name | instagram-post |
| description | Post a media file to Instagram with a caption. Operates against the shared headed Chrome — finds the existing Instagram tab if one is open, otherwise opens one. Use when the user says "post X to Instagram" or runs /instagram-post. |
Post to Instagram
Account: $0
Media: $1
Caption: $2
Reference: docs/platforms/instagram.md.
Step 0a: Snapshot tabs (for cleanup at end)
TAB_BASELINE="${HOME}/.social-skills/state/tab-baseline-instagram-post.json"
bash scripts/tab_baseline_save.sh "$TAB_BASELINE"
Step 1: Sanity checks
test -f "$1" && echo "media ok" || echo "MEDIA MISSING at $1"
If the media is missing, abort.
Step 1a: Resolve the actual IG handle (URL slug)
$0 is the env-var label that maps to .env credentials (e.g., INSTAGRAM_SWIFTBIBLE_USERNAME). The actual IG handle that appears in profile URLs is often different — e.g., the label swiftbible ↔ the handle @swift_bible (with underscore). Step 8b's post-publish verification needs the handle to build the right URL; using the label sends us to instagram.com/<label>/ which is a different (or non-existent) profile.
Resolve from config/brand.json: find the brand whose instagram.default_account matches $0, then take that brand's instagram.own_handle. Falls back to $0 if brand.json isn't readable or own_handle isn't set.
IG_HANDLE=$(jq -r --arg label "$0" '.brands | to_entries[] | select(.value.instagram.default_account == $label) | .value.instagram.own_handle // empty' config/brand.json 2>/dev/null)
[ -z "$IG_HANDLE" ] && IG_HANDLE="$0"
echo "IG_HANDLE=$IG_HANDLE (label=$0)"
Step 1b: Pre-pad tall iPhone media
Instagram aggressively crops content that doesn't match the aspect it expects (4:5 for feed photos, 9:16 for Reels). Pad before uploading.
Image (feed post → 4:5):
PADDED=$(bash scripts/pad_ios_screenshot.sh "$1" "" edge)
For an aesthetic feature post, prefer blur — polished album-art-style frame.
Video (Reel → 9:16):
PADDED=$(bash scripts/pad_ios_video.sh "$1" "" blur)
IG auto-converts vertical video uploads to Reels (web shows "Video posts are now shared as reels"). The flow below covers both image-feed-post and video-Reel paths; differences are called out per step.
Use $PADDED instead of $1 for the upload step. The original file is left untouched.
Step 2: Find or open the Instagram tab
bash scripts/switch_to_platform_tab.sh "instagram.com" "https://www.instagram.com/"
agent-browser wait --load networkidle
- After switching/opening, run
agent-browser wait --load networkidle.
If the page is on a login wall, abort and tell the user to run /instagram-login $0 first. The persistent browser should already be logged in; if not, state at ~/.config/agent-browser/instagram-$0.json can be loaded.
Step 3: Snapshot the home feed
agent-browser snapshot -i 2>&1 | head -40
Step 4: Click "Create" → "Post" (open the upload dialog)
The IG sidebar "Create" control (labeled New post Create) opens a small submenu (Post / Reel / …); clicking Post is what opens the upload dialog. This is the single flakiest step in the flow, for the same reason as the "Select crop" button in Step 6a: IG's React ignores JS .click() here, so agent-browser click @ref opens the submenu only sometimes. On the 2026-06-02 cron run this no-op burned ~5 minutes of SVG-hunting and reloads. Do NOT try the instagram.com/create/style/ URL — IG bounces it back to the home feed.
Find the Create ref and click it:
CREATE_REF=$(agent-browser snapshot -i 2>&1 | grep -E 'New post|"Create"' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
[ -z "$CREATE_REF" ] && { echo "[ig-post] Create button not in sidebar — aborting" >&2; exit 1; }
agent-browser click "$CREATE_REF"
agent-browser wait $(bash scripts/jitter.sh 700 1500)
Did the click land? Check for the Post submenu item OR the file input. If neither, the click no-op'd — retry as a real mouse event at the Create icon's center (the Step 6a "Select crop" technique):
STATE=$(agent-browser eval "(()=>{const post=Array.from(document.querySelectorAll('a,div[role=button],[role=link],span')).find(e=>e.textContent.trim()==='Post'&&e.offsetParent!==null);return JSON.stringify({hasPost:!!post,hasFile:!!document.querySelector('input[type=file]')})})()" 2>&1 | tail -1)
echo "after Create click: $STATE"
if ! echo "$STATE" | grep -qE '"has(Post|File)":true'; then
echo "[ig-post] Create click no-op'd — real-mouse retry" >&2
read -r CX CY < <(agent-browser eval "(()=>{const svg=document.querySelector('svg[aria-label=\"New post\"],svg[aria-label=\"Create\"]');const c=svg&&svg.closest('a,div[role=button],[role=link]');if(!c)return '0 0';const r=c.getBoundingClientRect();return Math.round(r.x+r.width/2)+' '+Math.round(r.y+r.height/2)})()" 2>&1 | tail -1 | tr -d '"')
if [ "${CX:-0}" != "0" ]; then
agent-browser mouse move "$CX" "$CY" && agent-browser wait 200 && agent-browser mouse down && agent-browser wait 100 && agent-browser mouse up
agent-browser wait $(bash scripts/jitter.sh 700 1500)
fi
fi
Click the Post submenu item if present (no-op if Create opened the dialog directly):
agent-browser eval "(()=>{const post=Array.from(document.querySelectorAll('a,div[role=button],[role=link],span')).find(e=>e.textContent.trim()==='Post'&&e.offsetParent!==null);if(post){post.click();return 'clicked Post'}return 'no submenu (dialog opened directly?)'})()"
agent-browser wait $(bash scripts/jitter.sh 800 1600)
Confirm the upload dialog is open before Step 5 — if there's still no file input after the click + real-mouse retry, abort instead of grinding (the grind is exactly what we're eliminating):
HASFILE=$(agent-browser eval "(()=>document.querySelector('input[type=file]')?'yes':'no')()" 2>&1 | tail -1 | tr -d '"')
[ "$HASFILE" != "yes" ] && { echo "[ig-post] no file input after Create→Post — aborting rather than grinding" >&2; exit 1; }
Then re-snapshot and proceed to Step 5.
Step 5: Upload the media
When the upload dialog is reachable, find the underlying <input type=file>:
agent-browser eval "Array.from(document.querySelectorAll('input[type=file]')).map(e=>({accept:e.accept,multiple:e.multiple}))"
The IG file input has multiple=true — pass multiple paths to agent-browser upload for a carousel post:
agent-browser upload "input[type=file]" "$PADDED"
agent-browser upload "input[type=file]" "$PADDED1" "$PADDED2"
Wait jitter after:
agent-browser wait $(bash scripts/jitter.sh 1500 3500)
For video uploads only: IG shows a "Video posts are now shared as reels" notice. Snapshot, find OK, click. The post will be a Reel even though we entered via "New post" / "Create".
agent-browser snapshot -i 2>&1 | grep -E '(OK|Learn more about Reels).*ref' | head -3
agent-browser click "@<OK_REF>"
agent-browser wait $(bash scripts/jitter.sh 1500 3000)
Step 6: Crop / Edit screens
agent-browser snapshot -i does NOT reliably surface the contents of IG's [role=dialog] modals (Crop, Edit, Sharing). Use eval to find and click buttons inside them.
Pattern for each modal — get the active dialog's label, then enumerate buttons:
agent-browser eval "(()=>{const dialogs=document.querySelectorAll('[role=dialog]');return Array.from(dialogs).map(d=>({label:d.getAttribute('aria-label'),visible:d.offsetParent!==null}))})()"
agent-browser eval "(()=>{const d=document.querySelector('[role=dialog][aria-label=Crop]');return Array.from(d.querySelectorAll('button,[role=button]')).map(b=>(b.textContent||b.getAttribute('aria-label')||'').trim())})()"
6a: Crop dialog — pick the right aspect ratio
The Crop dialog defaults to Original (which IG often interprets as 1:1 for ambiguous content). Vertical Reels need 9:16 / Mobile, padded feed photos want 4:5. Image gets aggressively cropped if the wrong aspect goes through; the only recovery is to back out and re-upload.
Critical: the "Select crop" button is the only step in this whole flow that does not respond to JS click(). IG's React listens for real pointer events on this control. Programmatic click() will NOT open the popover. Use agent-browser mouse move/down/up at the button's bbox center.
read -r SC_X SC_Y < <(agent-browser eval "(()=>{const d=document.querySelector('[role=dialog][aria-label=Crop]');const sc=Array.from(d.querySelectorAll('button')).find(b=>b.textContent.trim()==='Select crop');const r=sc.getBoundingClientRect();return Math.round(r.x+r.width/2)+' '+Math.round(r.y+r.height/2)})()" 2>&1 | tail -1 | tr -d '"')
echo "Select crop at ($SC_X, $SC_Y)"
agent-browser mouse move "$SC_X" "$SC_Y" && \
agent-browser wait 200 && \
agent-browser mouse down && \
agent-browser wait 100 && \
agent-browser mouse up
agent-browser wait $(bash scripts/jitter.sh 600 1200)
After the popover renders, the aspect options DO surface in agent-browser snapshot -i:
agent-browser snapshot -i 2>&1 | grep -E '(Original|1:1|4:5|16:9).*ref' | head -5
agent-browser click "@<ASPECT_REF>"
agent-browser wait $(bash scripts/jitter.sh 600 1200)
If the snapshot returns no aspect options, the popover didn't open — retry the real-mouse click (sometimes the first attempt times out on slow machines).
6b: Click Next through Crop → Edit → caption screen
agent-browser eval "(()=>{const d=document.querySelector('[role=dialog][aria-label=Crop]');const next=Array.from(d.querySelectorAll('button,[role=button]')).find(b=>b.textContent.trim()==='Next');next.click();return 'crop next'})()"
agent-browser wait $(bash scripts/jitter.sh 1500 2500)
agent-browser eval "(()=>{const d=document.querySelector('[role=dialog][aria-label=Edit]');const next=Array.from(d.querySelectorAll('button,[role=button]')).find(b=>b.textContent.trim()==='Next');next.click();return 'edit next'})()"
agent-browser wait $(bash scripts/jitter.sh 2000 3500)
Confirm the active dialog is now Create new post before continuing.
Step 7: Enter the caption
The caption textbox is textbox "Write a caption..." inside the Create new post dialog. Capture its ref correctly, abort if missing, verify length AND Lexical commit-state after typing, then blur to force Lexical to flush. keyboard type into a focus that didn't take produces no caption AND fires keystrokes into something else (it pops a "Discard post?" dialog). Confirmed live 2026-05-10 and 2026-05-11.
Critical grep gotcha: agent-browser snapshot -i outputs refs as [ref=e15], NOT @e15. The @e15 form is what we PASS to agent-browser focus/click. Many people (and earlier versions of this skill) wrote grep -oE '@e[0-9]+' which never matches anything in the snapshot — the script then focuses on an empty ref, typing bubbles, Discard pops. Use ref=e[0-9]+ then prefix with @.
CAPTION_REF=""
for try in 1 2 3; do
CAPTION_REF=$(agent-browser snapshot -i 2>&1 | grep -E 'Write a caption' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
[ -n "$CAPTION_REF" ] && break
agent-browser wait 1500
done
if [ -z "$CAPTION_REF" ]; then
echo "[ig-post] caption textbox not found after 3 snapshots — aborting" >&2
exit 1
fi
agent-browser focus "$CAPTION_REF"
agent-browser wait 400
agent-browser keyboard type "$CAPTION"
agent-browser wait $(bash scripts/jitter.sh 800 1600)
DISCARD=$(agent-browser eval "(()=>{const d=Array.from(document.querySelectorAll('[role=dialog],div')).find(x=>(x.textContent||'').startsWith('Discard post?'));return d?'yes':'no'})()" 2>&1 | tail -1 | tr -d '"')
if [ "$DISCARD" = "yes" ]; then
echo "[ig-post] Discard dialog popped — Cancelling and retrying caption" >&2
agent-browser eval "(()=>{const btns=Array.from(document.querySelectorAll('button'));const cancel=btns.find(b=>b.textContent.trim()==='Cancel');if(cancel)cancel.click();return 'cancelled'})()"
agent-browser wait 1000
CAPTION_REF=$(agent-browser snapshot -i 2>&1 | grep -E 'Write a caption' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
agent-browser focus "$CAPTION_REF"
agent-browser wait 400
agent-browser keyboard type "$CAPTION"
agent-browser wait $(bash scripts/jitter.sh 800 1600)
fi
COMMIT=$(agent-browser eval "(()=>{const ca=document.querySelector('[role=dialog][aria-label=\"Create new post\"]')?.querySelector('[aria-label=\"Write a caption...\"]');if(!ca)return{found:false};return{textLen:ca.textContent.length,lexicalSpans:ca.querySelectorAll('[data-lexical-text]').length}})()" 2>&1 | tail -10)
echo "$COMMIT"
LEN=$(echo "$COMMIT" | grep -oE '"textLen": [0-9]+' | grep -oE '[0-9]+')
SPANS=$(echo "$COMMIT" | grep -oE '"lexicalSpans": [0-9]+' | grep -oE '[0-9]+')
if [ -z "$LEN" ] || [ "$LEN" = "0" ] || [ -z "$SPANS" ] || [ "$SPANS" = "0" ]; then
echo "[ig-post] caption not committed to Lexical (len=$LEN spans=$SPANS) — retrying" >&2
agent-browser focus "$CAPTION_REF"
agent-browser wait 400
agent-browser keyboard type "$CAPTION"
agent-browser wait $(bash scripts/jitter.sh 800 1600)
fi
agent-browser eval "(()=>{const ca=document.querySelector('[role=dialog][aria-label=\"Create new post\"]')?.querySelector('[aria-label=\"Write a caption...\"]');if(ca)ca.blur();return 'blurred'})()"
agent-browser wait 800
Why keyboard type and not type @ref: IG's caption editor is a Lexical contenteditable div. agent-browser type @ref "multi\nline" swallows newlines and produces one run-on paragraph. keyboard type sends real Enter keystrokes, which Lexical converts to proper <br><br> paragraph breaks AND auto-styles #hashtags with the correct class="x7l2uk3 xt0e3qv" link spans.
Step 8: Share
The dialog's Share button isn't disambiguated in snapshot -i (it gets confused with feed Share buttons). Click via eval:
agent-browser wait $(bash scripts/jitter.sh 1500 3500)
agent-browser eval "(()=>{const d=document.querySelector('[role=dialog][aria-label=\"Create new post\"]');const sh=Array.from(d.querySelectorAll('button,[role=button]')).find(b=>b.textContent.trim()==='Share');sh.click();return 'clicked Share'})()"
Then poll the dialog label until it transitions Sharing → Post shared (videos take longer):
for i in 1 2 3 4 5 6 7 8; do
agent-browser wait 3000
STATE=$(agent-browser eval "(()=>{const d=document.querySelectorAll('[role=dialog]')[0];return d?d.getAttribute('aria-label'):'closed'})()" 2>&1 | tail -1)
echo "[$i] $STATE"
echo "$STATE" | grep -q 'Post shared' && break
done
agent-browser eval "(()=>{const d=document.querySelector('[role=dialog][aria-label=\"Post shared\"]');d.querySelector('[role=button]').click();return 'done'})()"
Step 8b: Post-publish caption verification + Edit recovery
Even with the Step 7 blur-to-flush, Lexical commit can still desync under load. Verify the live post received the caption; if not, recover via the post's Edit info dialog (which persists reliably — proven 2026-05-10 + 2026-05-11).
agent-browser open "https://www.instagram.com/${IG_HANDLE}/"
agent-browser wait --load networkidle
agent-browser wait 2000
LATEST=$(agent-browser eval "(()=>{const a=document.querySelectorAll('a[href*=\"/p/\"]');return a[0]?a[0].getAttribute('href'):''})()" 2>&1 | tail -1 | tr -d '"')
[ -z "$LATEST" ] && { echo "[ig-post] no recent post link found at /${IG_HANDLE}/ — manual check needed" >&2; exit 0; }
agent-browser open "https://www.instagram.com${LATEST}"
agent-browser wait --load networkidle
agent-browser wait 2000
CAPTION_HEAD=$(echo "$CAPTION" | head -c 40 | tr -d '"')
LIVE=$(agent-browser eval "(()=>{const main=document.querySelector('main');return main?main.textContent:''})()" 2>&1 | tail -1 | tr -d '"')
if ! echo "$LIVE" | grep -qF "$CAPTION_HEAD"; then
echo "[ig-post] live post is missing the caption — recovering via Edit dialog" >&2
MORE_REF=$(agent-browser snapshot -i 2>&1 | grep -E 'More options' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
agent-browser click "$MORE_REF"
agent-browser wait $(bash scripts/jitter.sh 600 1100)
EDIT_REF=$(agent-browser snapshot -i 2>&1 | grep -E '^\s*-?\s*button "Edit"' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
agent-browser click "$EDIT_REF"
agent-browser wait $(bash scripts/jitter.sh 1200 2000)
EDIT_CAP_REF=$(agent-browser snapshot -i 2>&1 | grep -E 'Write a caption' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
agent-browser focus "$EDIT_CAP_REF"
agent-browser wait 400
agent-browser keyboard type "$CAPTION"
agent-browser wait $(bash scripts/jitter.sh 800 1500)
agent-browser eval "(()=>{const ca=document.querySelector('[role=dialog][aria-label=\"Edit info\"]')?.querySelector('[aria-label=\"Write a caption...\"]');if(ca)ca.blur();return 'blurred'})()"
agent-browser wait 600
DONE_REF=$(agent-browser snapshot -i 2>&1 | grep -E '^\s*-?\s*button "Done"' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=/@/')
agent-browser click "$DONE_REF"
agent-browser wait 4000
echo "[ig-post] caption recovered via Edit info dialog" >&2
fi
Step 9: Verify and write the run log
agent-browser screenshot /tmp/instagram-post-$0-$(date +%Y-%m-%dT%H%M%S).png
Use the Write tool to create ~/.social-skills/logs/post/instagram-$0-<timestamp>.json:
{
"ts_start": "...",
"ts_end": "...",
"platform": "instagram",
"account": "$0",
"action": "post",
"outcome": "success | failed",
"media": "$1",
"caption": "...",
"tab_strategy": "switched | opened-new",
"form_fields_used": {
"create_button": "@<ref>",
"select_file": "@<ref>",
"next_buttons": ["@<ref>", "@<ref>"],
"caption_field": "@<ref>",
"share_button": "@<ref>"
},
"verification_screenshot": "/tmp/instagram-post-$0-<timestamp>.png"
}
Substitute <timestamp> with date +%Y-%m-%dT%H-%M-%S.
Do not close the tab — leave the browser as the user left it. The shared-browser model means the user may want to inspect the result.
Step 9a: Close spawned tabs
bash scripts/close_spawned_tabs.sh "$TAB_BASELINE"
rm -f "$TAB_BASELINE"
Step 10: Report
Tell the user:
- Outcome.
- Verification screenshot path.
- Run log path.
- If failed, the snapshot of the failing step and a guess at the cause.