I make a lot of small educational graphics. Each one used to cost me the same ten minutes: get the PNG onto my phone somehow, open Instagram, crop until it stops complaining, stare at the caption box, give up, type something lazy, forget alt text entirely.
So I moved the whole thing into a folder.
$ node post.js
Queue: 1 image(s) → 1 post(s). Mode: post oldest
Posting single "CLS Post-selection.png"
No caption file — generating with claude -p…
Saved caption to CLS Post-selection.txt (edit it and rerun to change)
Caption: "You went to tap Play and hit an ad instead — that's CLS, and it's the most rage-"…
Prepared CLS Post-selection.png: 1440x1800, 141KB, alt: "A dark-themed educational graphic titled \"CLS\", la"
Uploaded to Blob: CLS Post-selection.png
Published! Media ID: 18055517486775179
https://www.instagram.com/p/DbDlfNQm0n-/
Moved source file(s) to ~/Downloads/insta-queue/posted
Drop images in ~/Downloads/insta-queue, run the script, they show up on
Instagram. Getting Meta to allow this at all is its own
post. This one is about the design.
The queue is the interface
I didn’t want a config UI, a database, or a web dashboard. The filesystem is already a queue. It has ordering, naming, and a trash can. So every rule lives in filenames:
- Any image in the queue directory is a post, oldest first.
trip_1.jpg,trip_2.jpg: a shared numbered prefix becomes one carousel.photo.txtnext tophoto.jpgis its caption.photo.alt.txtis its alt text,photo.location.txtits location tag. These are sidecar files: they ride along with the image, linked by nothing but the shared name, the way Lightroom keeps edits in an.xmpnext to your raw photo.
After publishing, images and their sidecars move to a posted/ folder, so the
queue drains itself and the archive is a record of exactly what went out.
There’s no state anywhere else. Delete a file and the post is cancelled.
Rename two files and they become a carousel. Every “feature” is a file
operation you already know.
Workflow
From a file in Downloads to a live post
Every run walks the same seven stages. Nothing is stored between runs except the files themselves.
- 1
Scan the queue and group into post units
List images oldest-first, group prefix-numbered files into carousels, post the oldest unit (or everything, with --all).
- 2
Resolve caption, alt text, and location
Sidecar files win. If none exists, Claude writes one, and it lands on disk for review before anything publishes.
- 3
Preprocess each image
Force the aspect ratio into Instagram's window, cap the width, walk JPEG quality down under the size limit.
- 4
Upload to temporary public storage
Instagram only fetches images from public URLs, so each image becomes a short-lived public blob.
- 5
Create media containers
One container per image; carousels get child containers plus a parent holding caption and location.
- 6
Poll until ingestion finishes
Containers are asynchronous. Publishing before they reach FINISHED fails.
- 7
Publish, then clean up
Publish the container, fetch the permalink, move sources to posted/, delete the blobs.
Two surprises in the Graph API
First, it won’t take your file. There’s no image upload. You hand Instagram a public URL and its servers fetch it. Fine if your images already live on a CDN. Mine live in my Downloads folder. So the script uploads each image to Vercel Blob, lets Instagram fetch it, and deletes it the moment the post is live. The blob exists for a few seconds under an unguessable URL. A local-first tool with a five-second cloud detour.
Second, publishing takes three steps. You create a media container, wait while Instagram ingests it asynchronously, and only then publish. Carousels nest this: every image is its own container, and all of them have to finish before the parent container can even be created. Skip the waiting and you get failures that look random. Once you accept the container model, though, it’s honest. The async work is right there in the API shape.
Two smaller things worth knowing before you build against this API. There’s a
24-hour quota of 100 posts you can check upfront. And errors frequently come
back as HTTP 200 with an error object inside, where the useful explanation
hides in a field called error_user_msg. You’ll miss it if you only log the
top-level message.
Images have to be Instagram-shaped
Instagram rejects anything outside a 4:5 to 1.91:1 aspect ratio. My graphics are usually 4:5 already. Screenshots never are, and I refuse to think about aspect ratios at posting time. So the script normalizes everything: pad to the nearest legal ratio (or crop, per config), cap the width at 1440px, and re-encode at decreasing quality until the file is under 8MB.
Two traps in this stage cost me real time. Phone photos carry their rotation
in EXIF metadata, which Instagram ignores, so apply it yourself or photos
arrive sideways. And the prebuilt sharp binary can’t decode HEIC, so those
go through macOS’s built-in sips converter first. Not portable. Completely
fine for a script that runs on exactly one laptop.
Claude writes the captions, but I get the last word
The caption box is the reason I stopped posting, so this was the part that mattered most.
I didn’t reach for an API SDK. The claude CLI is already on my machine,
already authenticated, and can read image files directly, so the script just
runs it headless as a subprocess, restricted to read-only file access. The
prompt is blunt, because anything conversational leaks into the caption:
Look at the image file(s) listed below and write an Instagram caption for them as one post.
Style: Casual, engaging, 1-3 sentences, then 3-5 relevant hashtags on a new line.
Reply with ONLY the caption text — no quotes, no preamble, no explanation.
The design decision I care about: the generated caption is saved to disk as a sidecar file before anything publishes, and sidecar files always win over generation. That makes the whole thing a review loop. Dry-run, read what it wrote, fix the one word that’s off, run for real. The model gets me from a blank box to a draft. I still ship the final text.
The same mechanism writes alt text per image, prompted for screen readers. This is the feature I value most. I was never going to write alt text by hand for every post, and now every post has it.
The bug that had nothing to do with Instagram
Every API call was timing out. curl to the same host worked instantly.
Browser fine, ping fine, only Node broken.
It was happy eyeballs: Node 20+ races IPv6 and IPv4 connections by default, and on my network the IPv6 attempt neither connects nor fails fast, stalling every request. One line fixes it:
net.setDefaultAutoSelectFamily(false);
I lost an evening to that. If your Node scripts time out where curl succeeds, check this before you blame the API.
What’s next
A launchd job so the queue drains on a schedule. Automatic refresh for the
60-day token before it lapses quietly. Stories and Reels, which are separate
container types with their own quirks.
But the real change is that posting stopped being a decision. I export a graphic into a folder, and later a script does the rest. With alt text, which is more than I managed when I was doing it by hand.