diff --git a/.claude/hooks/route-skills.sh b/.claude/hooks/route-skills.sh
index 54b720898..7bbf89fdc 100755
--- a/.claude/hooks/route-skills.sh
+++ b/.claude/hooks/route-skills.sh
@@ -163,6 +163,17 @@ if has '(write|draft|author|compose).{0,24}(a |an |the )?(seo )?blog' \
add_match "webjs-blog-write: the request is to write or substantially edit a blog post. Invoke the webjs-blog-write skill BEFORE writing prose. It requires analyzing the author's voice by reading all blog/*.md first, and carries the hard rules (no em-dashes, no internal #NNN numbers in prose, no process tells), the SEO topic + front-matter conventions, the de-duplication check against every existing post, and the mandatory dogfood verification of every factual claim in a feature post."
fi
+# --- webjs-instagram-post: publish an SEO post to Instagram -------------
+# Triggers: post to Instagram, publish an Instagram post, share/promote a
+# page or feature on Instagram. Every post is SEO-only and MUST ship a
+# freshly created branded image plus a keyword-rich caption. The skill
+# holds the account id, the credential location (never printed), the image
+# recipe, the public-hosting requirement, and the confirm-first publish.
+if printf '%s' "$lc" | grep -q 'instagram' \
+ && has '(post|publish|share|promote|upload|announce|put up|schedule)'; then
+ add_match "webjs-instagram-post: the request is to publish to the WebJs Instagram account. Invoke the webjs-instagram-post skill. Every post is SEO-only, so ALWAYS create a fresh branded image plus a keyword-rich caption, host the JPEG at a public HTTPS URL, and CONFIRM the image and caption with the user before the public publish. Never print or commit the access token."
+fi
+
# --- code-review: review the diff before a PR is ready ------------------
# Triggers: review the PR/diff/branch/changes, code review, look it over
# for bugs. Reviewing every change before it is marked ready is a standing
diff --git a/.claude/skills/webjs-instagram-post/SKILL.md b/.claude/skills/webjs-instagram-post/SKILL.md
new file mode 100644
index 000000000..e7e0f850a
--- /dev/null
+++ b/.claude/skills/webjs-instagram-post/SKILL.md
@@ -0,0 +1,207 @@
+---
+name: webjs-instagram-post
+description: >
+ Publish an SEO post to the WebJs Instagram account (@webjs_dev). Use this
+ skill whenever the user asks to post to Instagram, publish an Instagram
+ post, share something on Instagram, or promote a page/feature/blog on
+ Instagram for SEO. Every post is strictly for SEO reach, so every post
+ MUST ship a freshly created branded image plus a keyword-rich caption.
+ The skill holds the account id, the credential location, the image recipe,
+ the public-hosting requirement, and the two-step publish flow. It never
+ prints or commits the access token.
+allowed-tools: Bash(curl:*), Bash(magick:*), Bash(convert:*), Bash(identify:*), Bash(cp:*), Bash(mkdir:*), Bash(ls:*), Bash(git:*), Read, Write
+---
+
+# Post to Instagram for SEO
+
+Every Instagram post on the WebJs account exists for SEO reach, not for
+personal updates. So each post carries two things you build here: a freshly
+created branded image, and a keyword-rich caption that names the capability
+and links back to a webjs.dev page. Never publish a plain-text post and never
+reuse a stale image.
+
+## Account and API
+
+- **Account** is the `@webjs_dev` professional (BUSINESS) account.
+- **Numeric user id** is `17841441038146182`. On `graph.instagram.com` you can
+ also address it as `me`.
+- **API flavor** is the Instagram API with Instagram Login, so every call goes
+ to `https://graph.instagram.com/v25.0/...` with a long-lived USER access
+ token. An app token (the `appid|secret` shape) can never post.
+
+## Credentials (never printed, never committed)
+
+The token lives in a gitignored file OUTSIDE the repo:
+
+```
+~/.config/webjs/instagram.env
+```
+
+with `IG_TOKEN`, `IG_USER_ID`, and `IG_APP_SECRET`. Source it inside a script
+and reference `${IG_TOKEN}`. NEVER `cat`, `echo`, or otherwise print the file,
+and NEVER copy the token into the repo, a commit, or a chat message. If the
+file is missing, ask the user to generate a long-lived user token from the
+Meta dashboard and place it there.
+
+```sh
+set -a; . ~/.config/webjs/instagram.env; set +a
+```
+
+## Step 1: create the branded SEO image (required, JPEG)
+
+The Instagram publishing API accepts a **JPEG only**, pulled from a public URL.
+`build-post.sh` renders at 2x supersample and outputs a sharpened **1440x1440**
+JPEG (quality 95) by default. This matters: a flat 1080 render looks soft in
+Instagram's DESKTOP full-size view on a high-DPI screen after IG re-compresses
+it, and the supersampled 1440 stays crisp. Do not drop back to a 1x 1080 render.
+
+Match the webjs.dev OG theme, which is a NEAR-BLACK card with a soft warm glow
+in the top-right corner, the orange squircle mark plus the white `webjs`
+wordmark, a bold white headline with ONE orange-highlighted phrase, gray
+subtext, and a small-caps footer meta row with a `webjs.dev` URL. It is a dark
+card with orange accents, NOT an orange background.
+
+`assets/build-post.sh` produces exactly this, parameterised by env var. Wrap the
+key headline phrase in an orange span, and bold a few subtext words in white:
+
+```sh
+OUT=/tmp/post.jpg \
+HEADLINE='The web framework for AI agents' \
+SUBTEXT='No build step. Built on web components, SSR, and progressive enhancement. Standards that outlast frameworks.' \
+KICKER='BUILT FOR THE AI ERA' \
+META='NO BUILD · WEB COMPONENTS · SSR' \
+ bash "$(dirname "$0")/assets/build-post.sh"
+```
+
+It needs ImageMagick with Pango. Always read `$OUT` back and eyeball it before
+publishing: check the headline does not collide with the subtext (the script
+stacks them dynamically, but a very long headline can still crowd), and that
+the footer meta does not overrun the `webjs.dev` URL.
+
+### Verify the thumbnail crop (do NOT skip this)
+
+The Instagram **profile grid** crops a 1:1 post to a **3:4 portrait**, keeping
+full height but trimming roughly 135px off EACH side of a 1080-wide image. So
+text that looks fine on the square card gets clipped, or sticks to the edge, in
+the grid thumbnail. `build-post.sh` already keeps every element inside a 200px
+safe margin for this reason, but ALWAYS confirm by simulating the crop and
+eyeballing it:
+
+```sh
+# 3:4 crop of the default 1440 output (keep full height, center 75% width)
+magick "$OUT" -gravity center -crop 1080x1440+0+0 +repage /tmp/grid-crop.jpg
+# open /tmp/grid-crop.jpg: no text may touch an edge, padding on both sides
+```
+
+If anything is tight, widen `PAD` in `build-post.sh` and shrink the headline /
+subtext `-size` widths, then rebuild and re-check. This took several iterations
+in practice, so treat the crop check as mandatory, not optional.
+
+## Step 2: host the image at a public HTTPS URL
+
+Meta fetches the image server-side, so it MUST sit at a public HTTPS URL before
+you publish. The on-brand, SEO-positive route is to commit it into the website
+and deploy, which also lands a real asset on webjs.dev:
+
+1. Copy the JPEG to `website/public/social/.jpg` in a worktree.
+2. Commit, push, open a PR, and after merge Railway serves it at
+ `https://webjs.dev/public/social/.jpg`.
+3. Confirm the URL returns `200` with `content-type: image/jpeg` before Step 4.
+
+For a quick one-off where a deploy is too heavy, any public HTTPS host works,
+as long as the final URL is reachable and serves real JPEG bytes.
+
+ALWAYS verify the hosted URL round-trips real bytes BEFORE publishing:
+
+```sh
+curl -s "$IMG" -o /tmp/verify.jpg && identify /tmp/verify.jpg
+```
+
+A host can return a URL yet store an EMPTY file (catbox did this in testing),
+and Instagram then fails the create-container call with a useless generic
+`{"error":{"code":1,"message":"An unknown error has occurred."}}`. If you see
+code 1, suspect the image URL first: confirm it downloads a valid JPEG of the
+right dimensions, and re-host on a different provider if not.
+
+## Step 3: write the SEO caption
+
+- First line names the capability with the primary keyword, since the first
+ line is what search and the feed show.
+- Add a short body that expands the value in plain words.
+- Close with a bare `webjs.dev/` reference (Instagram captions are not
+ clickable, but the text is still indexable and it drives to the bio link).
+- Append a focused hashtag set. Reuse a core set and add a few per-topic ones,
+ for example `#webdevelopment #javascript #webcomponents #framework #ssr
+ #frontend #buildless #aicoding`.
+
+Keep it honest about what WebJs does. Do not oversell, and do not frame a
+WebJs feature as another tool's branded feature.
+
+## Step 4: publish (confirm first, it is public)
+
+Publishing is public and hard to undo, so ALWAYS confirm the exact image and
+caption with the user before this step. Check the quota first (limit is 25 per
+rolling 24 hours).
+
+```sh
+set -a; . ~/.config/webjs/instagram.env; set +a
+IMG="https://webjs.dev/public/social/why-webjs.jpg"
+CAP="Build on the platform, not against it. WebJs is a no-build, web-components-first framework. webjs.dev/why #webdevelopment #javascript #webcomponents"
+
+# quota
+curl -s "https://graph.instagram.com/v25.0/me/content_publishing_limit?access_token=${IG_TOKEN}"
+
+# 1) create the media container
+CREATE=$(curl -s "https://graph.instagram.com/v25.0/me/media" \
+ --data-urlencode "image_url=${IMG}" \
+ --data-urlencode "caption=${CAP}" \
+ --data-urlencode "access_token=${IG_TOKEN}")
+echo "$CREATE"
+CREATION_ID=$(printf '%s' "$CREATE" | grep -oE '"id":"[0-9]+"' | head -1 | grep -oE '[0-9]+')
+
+# 2) WAIT until the container is FINISHED. Publishing too fast fails with
+# code 9007 "Media ID is not available" even for a static image.
+for i in $(seq 1 25); do
+ ST=$(curl -s "https://graph.instagram.com/v25.0/${CREATION_ID}?fields=status_code&access_token=${IG_TOKEN}")
+ printf '%s' "$ST" | grep -q '"status_code":"FINISHED"' && break
+ printf '%s' "$ST" | grep -q '"status_code":"ERROR"' && { echo "container ERROR: $ST"; break; }
+done
+
+# 3) publish the container
+curl -s "https://graph.instagram.com/v25.0/me/media_publish" \
+ --data-urlencode "creation_id=${CREATION_ID}" \
+ --data-urlencode "access_token=${IG_TOKEN}"
+echo
+```
+
+Only the API JSON is ever printed, so the token stays out of the transcript.
+A successful publish returns the new media id. Report that id to the user.
+
+## Token refresh (keeps the account posting)
+
+A long-lived token lasts 60 days and is refreshable any time after it is 24
+hours old. Refresh on a schedule so it never lapses:
+
+```sh
+set -a; . ~/.config/webjs/instagram.env; set +a
+curl -s "https://graph.instagram.com/v25.0/refresh_access_token?grant_type=ig_refresh_token&access_token=${IG_TOKEN}"
+```
+
+Then write the returned token back into `~/.config/webjs/instagram.env` without
+printing it.
+
+## The profile avatar asset
+
+`assets/webjs-instagram-avatar.png` is the 1080x1080 profile picture for the
+account. It is full-bleed brand orange with the white `webjs` wordmark, so
+Instagram's circular crop yields a clean filled circle rather than the clipped
+squircle the old favicon produced. It is set manually in the Instagram app (the
+publishing API cannot change a profile picture). Regenerate it with:
+
+```sh
+magick -size 1080x1080 -define gradient:angle=135 gradient:'#FF8C3A'-'#DE5F10' base.png
+magick -background none -fill white -font Adwaita-Sans-ExtraBold -kerning -8 \
+ -size 800x -gravity center label:'webjs' wordmark.png
+magick base.png wordmark.png -gravity center -composite \
+ assets/webjs-instagram-avatar.png
+```
diff --git a/.claude/skills/webjs-instagram-post/assets/build-post.sh b/.claude/skills/webjs-instagram-post/assets/build-post.sh
new file mode 100755
index 000000000..d1173466d
--- /dev/null
+++ b/.claude/skills/webjs-instagram-post/assets/build-post.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+#
+# Build one on-theme WebJs Instagram post image that matches the webjs.dev OG
+# cards: near-black background, a soft warm glow in the top-right corner, the
+# orange squircle mark plus the white "webjs" wordmark, a bold white headline
+# with one orange-highlighted phrase, gray subtext, and a small-caps footer
+# meta row with a "webjs.dev" URL.
+#
+# Sharpness: the whole card is rendered at SS x supersample (default 2, so a
+# 2160px canvas) and downscaled with Lanczos + a light unsharp to the final
+# size. This keeps the logo and text crisp in Instagram's DESKTOP full-size
+# view (a flat 1080 render looks soft there on a high-DPI screen after IG
+# re-compresses it). Output defaults to 1440x1440 at JPEG quality 95.
+#
+# Override the copy per post via env vars (all optional):
+# HEADLINE pango markup, wrap the key phrase in
+# SUBTEXT pango markup, use
+# KICKER top-right label text (uppercase reads best)
+# META footer tags, " · " separated
+# OUT output path (default ./post.jpg)
+# OUT_SIZE final square px (default 1440)
+# SS supersample factor (default 2)
+#
+# Requires ImageMagick with Pango (magick -list format | grep -i pango).
+set -euo pipefail
+cd "$(dirname "$0")"
+
+OUT="${OUT:-post.jpg}"
+OUT_SIZE="${OUT_SIZE:-1440}"
+SS="${SS:-2}"
+KICKER="${KICKER:-BUILT FOR THE AI ERA}"
+META="${META:-NO BUILD · WEB COMPONENTS · SSR}"
+HEADLINE="${HEADLINE:-The web framework for AI agents}"
+SUBTEXT="${SUBTEXT:-No build step. Built on web components, SSR, and progressive enhancement. Standards that outlast frameworks.}"
+
+# Everything below is expressed in 1080-design units, multiplied by SS so the
+# card renders on an (1080*SS) canvas and is downsized to OUT_SIZE at the end.
+s() { echo $(( $1 * SS )); } # scale one integer
+CANVAS=$(s 1080)
+
+W=/tmp/webjs-ig-post
+mkdir -p "$W"
+
+# background: dim orange radial cropped so the core sits in the top-right corner
+magick -size $(s 2160)x$(s 2160) radial-gradient:'#4a2a14'-'#0a0a0a' \
+ -crop ${CANVAS}x${CANVAS}+0+${CANVAS} +repage "$W/bg.png"
+
+LOGO=$(s 76); LR=$(( LOGO - 1 )); LRAD=$(s 22)
+magick -size ${LOGO}x${LOGO} -define gradient:angle=135 gradient:'#FF9A45'-'#E06110' \
+ \( -size ${LOGO}x${LOGO} xc:none -fill white -draw "roundrectangle 0,0,${LR},${LR},${LRAD},${LRAD}" \) \
+ -alpha off -compose CopyOpacity -composite "$W/logo.png"
+
+magick -background none -fill white -font Adwaita-Sans-ExtraBold -pointsize $(s 42) \
+ -kerning $(s -1) label:'webjs' "$W/wordmark.png"
+
+magick -background none \
+ pango:"${KICKER}" \
+ "$W/kicker.png"
+
+# Text sits inside the center-safe column so Instagram's 3:4 profile-grid crop
+# (trims ~12.5% off each side) never clips it.
+magick -background none -size $(s 660)x \
+ pango:"${HEADLINE}" \
+ "$W/headline.png"
+
+magick -background none -size $(s 640)x \
+ pango:"${SUBTEXT}" \
+ "$W/subtext.png"
+
+magick -background none \
+ pango:"● ${META}" \
+ "$W/footer-left.png"
+magick -background none \
+ pango:"webjs.dev" \
+ "$W/footer-right.png"
+
+magick -size $(s 660)x$(s 2) xc:'#282828' "$W/divider.png"
+
+# 200px left/right safe margin (in design units) so the grid crop leaves
+# comfortable padding, not text touching the thumbnail edge.
+PAD=$(s 200)
+HY=$(s 336)
+H_head=$(identify -format '%h' "$W/headline.png")
+SY=$(( HY + H_head + $(s 36) ))
+
+magick "$W/bg.png" \
+ "$W/logo.png" -gravity northwest -geometry +${PAD}+$(s 86) -composite \
+ "$W/wordmark.png" -gravity northwest -geometry +$(( PAD + $(s 92) ))+$(s 104) -composite \
+ "$W/kicker.png" -gravity northeast -geometry +${PAD}+$(s 112) -composite \
+ "$W/headline.png" -gravity northwest -geometry +${PAD}+${HY} -composite \
+ "$W/subtext.png" -gravity northwest -geometry +$(( PAD + $(s 2) ))+${SY} -composite \
+ "$W/divider.png" -gravity northwest -geometry +${PAD}+$(s 902) -composite \
+ "$W/footer-left.png" -gravity northwest -geometry +${PAD}+$(s 936) -composite \
+ "$W/footer-right.png" -gravity northeast -geometry +${PAD}+$(s 940) -composite \
+ "$W/composed.png"
+
+# Downscale with Lanczos + a light unsharp so glyph edges stay crisp after
+# Instagram re-compresses, and output a high-quality JPEG.
+magick "$W/composed.png" -filter Lanczos -resize ${OUT_SIZE}x${OUT_SIZE} \
+ -unsharp 0x0.6+0.6+0 -colorspace sRGB -strip -quality 95 "$OUT"
+
+identify -format 'built %f %wx%h %m %b\n' "$OUT"
diff --git a/.claude/skills/webjs-instagram-post/assets/webjs-instagram-avatar.png b/.claude/skills/webjs-instagram-post/assets/webjs-instagram-avatar.png
new file mode 100644
index 000000000..d6faf02c7
Binary files /dev/null and b/.claude/skills/webjs-instagram-post/assets/webjs-instagram-avatar.png differ
diff --git a/test/hooks/route-skills.test.mjs b/test/hooks/route-skills.test.mjs
index 653d5e0da..7cf53ea9d 100644
--- a/test/hooks/route-skills.test.mjs
+++ b/test/hooks/route-skills.test.mjs
@@ -135,6 +135,23 @@ test('webjs-blog-write stays quiet on a docs-sync prompt', () => {
assert.ok(!routed(ctx, 'webjs-blog-write'), 'doc-sync prompt must not route to blog-write');
});
+test('webjs-instagram-post routes on instagram-posting phrases', () => {
+ for (const p of [
+ 'post this to instagram',
+ 'publish an instagram post about the new feature',
+ 'share the why page on instagram for SEO',
+ 'promote the streaming actions on instagram',
+ ]) {
+ const { ctx } = run(p);
+ assert.ok(routed(ctx, 'webjs-instagram-post'), `expected instagram route for: ${p}`);
+ }
+});
+
+test('webjs-instagram-post stays quiet without instagram intent', () => {
+ const { ctx } = run('publish the release notes to the changelog');
+ assert.ok(!routed(ctx, 'webjs-instagram-post'), 'non-instagram publish must not route to instagram');
+});
+
test('code-review routes on review phrases', () => {
for (const p of [
'review the PR',