# I Post to Instagram From My Terminal Now

> A small Node.js CLI that turns a Downloads folder into an Instagram publishing queue. Filenames as the interface, Vercel Blob as a five-second host, and Claude writing captions and alt text I actually review.

By Vishwajeet Raj · 2026-07-22 · https://vishwajeet.co/blog/posting-to-instagram-from-my-terminal

---

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.

```bash
$ 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](/blog/instagram-api-setup). 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.txt` next to `photo.jpg` is its caption. `photo.alt.txt` is its alt
  text, `photo.location.txt` its 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 `.xmp` next 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.

> _(interactive demo — view it on the live post)_

## 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:

```js
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.
