Route B (Generated Image) + Instagram — Scope Doc
Product: AI Workforce — Remy (social_publisher) Feature: Generate and host a real per-post image (Route B) for Facebook, and unblock Instagram as a delivery channel — the two are scoped together because Instagram structurally requires Route B. Status: Exploration / Design — not scheduled, no implementation started. Successor to docs/product/og-image-facebook-scope.md §7/§8/§10 Decision 5, which deferred both here. Date: 2026-07-21 Owner: Josh Lockhart (decisions pending — see §9)
1. Executive Summary & Recommendation
Facebook shipped in v4.9 as Route A — link-preview posts that reuse an existing marketing page's og: tags, with zero image-generation infrastructure (docs/product/og-image-facebook-scope.md). That doc explicitly deferred two things together, because both need the same underlying capability — "generate and host a real image":
- Route B for Facebook: posts with no natural landing URL, sent via Buffer's
assetsfield instead oflinkAttachment. - Instagram, for any post: Buffer's live-introspected schema confirms
InstagramPostMetadataInputhas nolinkAttachmentfield at all (og-image-facebook-scope.md§2, §7) — Route A is structurally impossible for Instagram. Every Instagram post must ship a native image asset viaassets. There is no "just add Instagram" path that skips Route B.
This doc does not decide anything — it surfaces the open forks with a recommendation each, for Josh to answer in one pass (§9), the same way og-image-facebook-scope.md §10 was resolved. No implementation should start on Route B or Instagram until that pass happens.
What's cheap, what's not, based on what's actually on master today (verified 2026-07-21, not inherited from the 2026-07-19 Facebook doc):
- The storage layer needs no new infrastructure —
ImageAssetInput.urlin Buffer's schema is a public URL Buffer fetches from, and the existinggoogleActiveStorage service is alreadypublic: true(§3). The only real gap iscache_controlpolicy for a permanent asset (§3). - The admin review surface is further along than the Facebook doc's §6 assumed it would be when this doc got picked up. The Facebook-aware link-preview card that
og-image-facebook-scope.md§6 described (and that was briefly deleted by PR #1638/OBJ-1562) has since been restored — asFocusDetailOverlay.vue, not the originalReviewQueueView.vue— via PR #1652 (OBJ-1576). What genuinely does not exist yet is an<img>preview of a hosted asset; today's card only renders a clickable link. See §5 — this is a smaller remaining gap than originally scoped, not a from-scratch build. - The image itself is the real open question: no render pipeline (headless-browser screenshot, dynamic server-rendered text-to-image, or similar) exists anywhere in this stack today. The cheapest path is reusing/redesigning static marketing assets — see §2.
- Instagram's exact field contract is still largely unverified — no live Buffer credentials were available when this doc was written (Codi's introspection attempt on this issue, 2026-07-21, came back "credentials unavailable," not a guess). See §4.
2. Image Template / Render Decision
og-image-facebook-scope.md §3 named three options for where an OG image could come from; Route A picked "point at an existing page's og:image." Route B needs an actual generated-or-reused asset, so the same three options apply here for real, not as a Route-A aside:
- Static, per-post-type templates — a small fixed set of pre-designed image templates (e.g. one per Content Beat Framework beat — see §6) with post-specific text or numbers swapped in at generation time. Requires some render step (even a simple one) unless the set is small enough to hand-design and store as static files.
- Dynamic, server-rendered images from post text + design tokens — render a bespoke image per post at delivery time. This is net-new infrastructure: confirmed against
rails_api/Gemfileandagent_runner/package.json, there is no headless-browser screenshot step (no Puppeteer/Playwright-as-a-service, nowkhtmltoimage, no image-rendering gem) anywhere in the stack today. This is the most flexible option and the most expensive to build and operate. - Reuse/redesign existing marketing assets — take an asset that already exists (or a small redesigned set) and serve it as-is via Route B's
assetsfield, the same way Route A points at an existing page.
Recommendation: start with option 3, informed by what's already live. marketing_landing/images/social/og-card.png (1200×630, brand-token-driven) already exists and — as of 2026-07-21, verified directly against marketing_landing/index.html lines 15–17 — is now wired into the marketing page's own og:image/og:image:width/og:image:height tags. This corrects og-image-facebook-scope.md's own Tech Debt note and the v4.9 milestone narrative's Tech Debt section (OBJ-1575), both of which state this wiring was never done — master has moved since those were written. That single card is not enough for a "generated" experience (it's still one static image, not per-post), but it proves the token-driven design pipeline already produces a usable landscape asset, and it's the cheapest possible starting point for a small template set: redesign 2–4 more variants (mirroring the beat framework in §6) rather than building a render service.
Brand tokens for any template work, verified directly on master (not re-derived from the Facebook doc's prose):
design_system/tailwind-tokens.cjs—primary/primary.light/primary.glow/secondary/accent/accent.light/destructive/gold/coach/mutedcolor families,sans: ['Inter', ...],display: ['Outfit', ...].design_system/css/tokens.css— the actual HSL values behind those tokens, e.g.--primary: 203 78% 41%(light) /203 78% 61%(dark),--accent: 27 89% 55%. Confirm the current values here before hardcoding a hex/HSL literal in any template — dark/light variants differ (lines 17–21 vs. 174–188).- No existing "stat card" / "streak card" component in
design_system/src/components/renders as a static export-ready image —gallery/ComponentCard.vue,ShadowCard.vue,GradientCard.vueare Storybook-gallery components, not a social-card generator. A template built from option 1 or 3 would be new design work, not a reuse of an existing component.
Do not decide between options 1/2/3 here — this is the first fork for §9.
3. Storage & Serving
Recommendation, carried forward from og-image-facebook-scope.md §4 and re-verified against current master: ActiveStorage on the existing google service (rails_api/config/storage.yml) — no new infrastructure.
Verified directly against rails_api/config/storage.yml (2026-07-21):
google:
service: GCS
project: <%= ENV.fetch('GCP_PROJECT_ID', 'enkidu-488723') %>
bucket: <%= ENV.fetch('GCS_BUCKET_NAME', 'enkidu-storage') %>
public: true
cache_control: "public, max-age=300"public: trueand a public GCS bucket mean an ActiveStorage attachment's.urlis directly usable asImageAssetInput.url— Buffer fetches from that URL; there is no presigned-upload or multipart step to build.cache_control: "public, max-age=300"(5 minutes) is a single, service-wide setting — there is no existing pattern anywhere inrails_api/appfor a per-attachment or per-asset-classcache_controloverride (checked: no othercache_controlreference exists in the codebase outside this one line). A permanent social-card image that Buffer/Facebook/Instagram may cache and re-fetch shouldn't reuse a TTL tuned for mutable user uploads. Two real options, not yet decided:- Raise
cache_controlfor a new, dedicated asset class (e.g. a secondgoogle_social_cards:service entry instorage.ymlwith a long TTL, or an ActiveStorageservice:override on the specific attachment). - Accept the 300s default and the resulting frequent re-fetches.
- Raise
This is the second fork for §9 — pick one before implementation.
4. Instagram Field Contract
Live Buffer GraphQL introspection was attempted for this doc and did not run — SOCIAL_AGGREGATOR_API_KEY was unavailable in the environment (Codi, 2026-07-21, this issue). Per house style, fields below are marked UNVERIFIED rather than guessed; re-run introspection before implementation.
| Field / type | Status | Source |
|---|---|---|
InstagramPostMetadataInput.type | PostType! — confirmed NON_NULL | og-image-facebook-scope.md §2 (live-verified 2026-07-19) |
InstagramPostMetadataInput.shouldShareToFeed | Boolean! — confirmed NON_NULL | og-image-facebook-scope.md §2 (live-verified 2026-07-19) |
InstagramPostMetadataInput.linkAttachment | confirmed absent — no such field | og-image-facebook-scope.md §2/§7 (live-verified 2026-07-19) — this is why Instagram is Route-B-only |
PostType enum members for Instagram | UNVERIFIED | Not introspected. og-image-facebook-scope.md's equivalent Facebook enum (PostTypeFacebook) has post/story/reel; whether Instagram's PostType is shaped the same way is unconfirmed. |
stickerFields | UNVERIFIED — presence, shape, nullability all unknown | Not introspected |
geolocation | UNVERIFIED — presence, shape, nullability all unknown | Not introspected |
AssetInput / ImageAssetInput aspect-ratio, dimension, format, or size constraints | UNVERIFIED — unknown whether Buffer's schema encodes these or whether they're Instagram-platform rules enforced outside GraphQL | Not introspected. ImageAssetInput { url: String!, thumbnailUrl, metadata } shape itself is confirmed (og-image-facebook-scope.md §2), but no constraint fields were found on it during the original Facebook-focused introspection — that pass wasn't looking for Instagram-specific limits. |
| Reminder-based publishing model | UNVERIFIED | Named as a thing to cover in og-image-facebook-scope.md §7, but no source — repo or Buffer schema — was found describing it during this pass. Likely refers to Instagram's own platform behavior (outside Buffer) where certain post types require a mobile-app confirmation step rather than being fully API-publishable; needs an Instagram/Buffer product-doc read, not a schema introspection, to confirm. |
Do not build against any UNVERIFIED row without re-running live introspection first (the same buffer:* rake tooling used for the Facebook Phase 0 verification, per og-image-facebook-scope.md §8, applies here — no new tooling needed, just a live credential).
5. Admin Review Surface
Current state on master, verified 2026-07-21 — this is a correction to what this doc's originating task package assumed:
The dedicated Facebook-aware panel in the admin dashboard was briefly deleted (ReviewQueueView.vue, removed by PR #1638/OBJ-1562 during the Live Ops Board route cutover) but has since been restored, in a different component, via PR #1652 (OBJ-1576, fix(admin-dashboard): port Facebook social_post review affordance into FocusDetailOverlay). Today, admin_dashboard/src/components/ops/FocusDetailOverlay.vue (backed by the view-model in admin_dashboard/src/types/workItem.ts and useWorkforceBoard.ts) renders, per social_post artifact:
- A "Link preview" card when
item.socialPost.hasFacebookis true — a clickable link toitem.socialPost.linkUrl, gated byisSafeLinkUrl()(workItem.ts), which allowlists onlyhttp:/https:schemes before the LLM-authored URL is ever used as anhref. This is the same scheme-allowlist requirementog-image-facebook-scope.md§6 called for. - Per-channel text (
ch.text) in a<pre>block, with a character counter againstSOCIAL_CHANNEL_LIMITS({ x: 280, linkedin: 3000 }) where a hard limit exists, and a plain count where it doesn't (Facebook has no hard cap, matchingog-image-facebook-scope.md§6's original recommendation). SOCIAL_CHANNEL_LABELScurrently maps{ x: 'X', linkedin: 'LinkedIn', facebook: 'Facebook' }.
What's still missing, and what Route B genuinely still needs: the link-preview card is text-and-link only — there is no <img> anywhere in this component. An admin approving a Route B post (Facebook or Instagram) needs to see the actual hosted image before approving, not just a link to click through. Required additions:
- An
<img :src="item.socialPost.imageUrl">(or equivalent) rendered when a post carries an asset rather than a link — needs a newimageUrl(or similar) field threaded throughuseWorkforceBoard.ts'sbuildSocialPost()the same waylinkUrlis today. - Add
instagram: 'Instagram'toSOCIAL_CHANNEL_LABELS, and a soft/no character limit similar to Facebook's, once Instagram ships. - The existing
isSafeLinkUrl()scheme-allowlist pattern should be reused verbatim for any Route B image URL rendered as an<img src>— a hosted-asset URL is the same LLM-authored-input injection surface a link URL was; do not skip this gate just because the value is going intosrcinstead ofhref.
This is meaningfully less new work than originally scoped — the channel/label/counter/scheme-allowlist infrastructure is already built and Instagram-ready; only the image-rendering piece is net-new.
6. Change Surface, Itemized
Assuming the §9 decisions land in favor of building Route B + Instagram (not a foregone conclusion — that's what §9 is for):
rails_api/app/services/ai/social_publisher_client.rb- Add
'instagram' => 'BUFFER_PROFILE_ID_INSTAGRAM'toCHANNEL_ENV_KEYS(currentlyx/twitter/linkedin/facebook,social_publisher_client.rb:20-25). - The mutual-exclusivity guard already exists and is live:
create_post(social_publisher_client.rb:154-155) raisesArgumentErroriflink_url.present? && assets.present?. This guard was built in v4.9 as forward-defense specifically for this work (per its own comment,social_publisher_client.rb:149-153) and is currently unreachable in production because every caller sendsassets: []. Route B is the first caller that will actually populateassetswith real entries — this is expected to finally exercise that guard, not something that needs to be rebuilt. create_post'sinput:hash needs anassets:payload with realImageAssetInputentries instead of always[], and — per Instagram's confirmed-NON_NULLfields (§4) — an Instagram branch of themetadata:key analogous to the existingmetadata: { facebook: { ... } }branch (social_publisher_client.rb:167).- Update the class header comment (
social_publisher_client.rb:1-13), which currently reads "Channel scope: X + LinkedIn + Facebook. Instagram is still deferred..." — this needs updating once Instagram ships, the same way it was updated for Facebook in v4.9.
- Add
rails_api/app/jobs/ai_workforce/deliver_artifact_job.rbdeliver_social_post(deliver_artifact_job.rb:144) currently threadslink_urlthrough for Facebook and permanently fails iffacebookis requested with a blanklink_url(deliver_artifact_job.rb:156-160). Route B needs the equivalent guard for an image field (e.g.image_urlor similar) — permanently fail if a Route-B-only channel (Instagram, or Facebook-via-Route-B) is requested with no image, mirroring the existing pattern exactly rather than introducing a new failure-handling shape.- The
delivered_at-iff-all-channels-succeeded invariant (deliver_artifact_job.rb:112-114) and thebuffer_updatesretry-safety merge logic that follows it (deliver_artifact_job.rb:116-124) must not be weakened — Instagram/Route B are additional entries in the existing genericchannels/contentloop, not a special case.
rails_api/lib/tasks/ai_workforce.rake— two spots, same as the Facebook change in v4.9:- The
description:field (~line 121, currently "Drafts scheduled social posts (X, LinkedIn, Facebook) from product milestones...") needs "Instagram" added once it ships. - The
task_prompt(~line 132) currently instructs Remy to includelink_urlwheneverfacebookis inchannels(lines 143–149) and references the five-beat Content Beat Framework (.claude/skills/orchestrating-social-rhythm/SKILL.md, already adopted per v4.9's Decision 6). That framework already anticipates Instagram: "Feature reveal" and "Progress proof" beats are taggedInstagramas a channel fit, with formats ("Screenshot + 1-sentence hook", "Streak/XP card visual") that assume a real image exists — i.e., the beat taxonomy is already Instagram-ready, only the delivery path is missing. The prompt needs an Instagram-specific field (an image reference, description, or generation instruction — depends on which §2 option is chosen) the same waylink_urlwas added for Facebook.
- The
BUFFER_PROFILE_ID_INSTAGRAM— new env var, same shape asBUFFER_PROFILE_ID_FACEBOOK. No Instagram channel ID has been confirmed connected in Buffer yet (unlike Facebook, which had a live channel ID by the timeog-image-facebook-scope.mdwas written) — this needs a real Buffer-side Instagram channel connection before a value exists.deploy/worker.staging.yaml/deploy/worker.production.yaml— correction, re-verified 2026-07-21: these are NOT mirrored. Onlydeploy/worker.production.yaml:169-175carriesBUFFER_PROFILE_ID_TWITTER,BUFFER_PROFILE_ID_LINKEDIN,BUFFER_PROFILE_ID_FACEBOOK, andSOCIAL_AGGREGATOR_API_KEY.deploy/worker.staging.yamlcarries none of them — production's file has an explicit in-place comment stating this is intentional: "Production-only by design — staging is deliberately left unwired... There is one Buffer organization and one set of live channels, so wiring both environments would point staging at production's live brand accounts for no safety benefit." Staging's own matching comment (deploy/worker.staging.yaml:162-176) elaborates further: Buffer has no sandbox, so a "staging" run would post to the same real channels — not a safer rehearsal, just the same act — and namesscripts/check-deploy-secret-drift.mjsas the guard that exists specifically to block staging silently sharing a live production resource (cf. OBJ-1341). This is in tension with §9 item 5's "same shared-channel exception" framing below — the shared-channel precedent (og-image-facebook-scope.md§10 Decision 4) is not the same thing as shared-deploy-wiring, and today only production has the env vars wired at all. WhetherBUFFER_PROFILE_ID_INSTAGRAMfollows the same production-only pattern, or something else, is not decided here — see §9 item 5, which now poses this explicitly rather than assuming it away.- Admin dashboard — see §5 for the itemized
<img>preview and label additions. - Storage — see §3 for the
cache_controldecision and any newstorage.ymlservice entry. CLAUDE.md'sAiArtifactdelivery gotcha — currently reads (accurately, as of 2026-07-21): "social_post→ Buffer scheduled drafts (Ai::SocialPublisherClient, X, LinkedIn, and Facebook — Facebook ships as link-preview posts only, Route A, no generated image; Instagram and the generated-image Route B remain deferred, seedocs/product/og-image-facebook-scope.md)." This needs two updates once Route B/Instagram ship: point at this doc instead of (or alongside) the Facebook doc, and drop "remain deferred" for whichever of Route B/Instagram actually ships.
7. Phase Breakdown & Dependencies
Not scheduled — this section names likely phases and their dependencies for whenever this is picked up, it does not commit to an order. Actual sequencing depends on §9's decisions, especially whether Route B ships for Facebook before, with, or after Instagram.
- R0 — Render decision (§2): blocks everything downstream that touches an actual image. Must resolve which of the three options (static template / dynamic render / reuse-redesign) before any asset pipeline work starts.
- R1 — Storage & cache_control (§3): can start in parallel with R0 once a decision on cache_control policy is made; doesn't depend on which render option wins.
- R2 — Buffer client + job wiring for real
assets(§6 items 1–2): depends on R0 (needs a real image URL to send) and R1 (needs that URL to be a working public GCS URL). This is also the first time the existing mutual-exclusivity guard becomes reachable in a live code path — treat the first real Route B send as a mini Phase-0-style live-verify, the same way v4.9's Phase 0 verifiedsaveToDraftagainst a real channel before trusting it. - R3 — Admin
<img>preview (§5): depends on R2 existing (nothing to preview without a real asset field on the artifact payload). Can be built against a stubbed/test asset URL before R2 fully lands if desired. - R4 — Instagram channel wiring (§4, §6 items 3–5): depends on R0–R2 (Instagram is Route-B-only, so the general Route B pipeline must exist first) and on live Buffer introspection actually closing the
UNVERIFIEDrows in §4 — do not build against a guessed field shape. - Must-verify-live list (do not trust until confirmed against real channels):
- Every
UNVERIFIEDrow in §4 — re-run Buffer introspection with real credentials before writing any Instagram-specific code. - The first real (non-empty)
assetspayload throughcreate_post— confirm Buffer accepts theImageAssetInputshape as constructed and that the image actually renders correctly on the receiving platform. - Whatever the "reminder-based publishing model" (§4) turns out to mean for Instagram — confirm before assuming Instagram posts behave like Facebook/X/LinkedIn's fully-API-driven
saveToDraftflow.
- Every
8. Hard Constraints Restated (do not weaken)
Unchanged from og-image-facebook-scope.md §9 — Route B and Instagram do not get an exception to any of these:
- Remy remains hard-capped at
shadowautonomy (AiEmployee::SHADOW_LOCKED_ROLESincludessocial_publisher,ai_employee.rb:14) — every post, on every channel including a future Instagram, requires admin approval via the review surface (§5). - Buffer entries must remain scheduled drafts (
saveToDraft: true,social_publisher_client.rb:164) — never immediate publish. Instagram/Route B use the identicalcreatePostcall path; no channel-specific override should be introduced. - The
delivered_at-iff-all-channels-succeeded invariant (deliver_artifact_job.rb:112-114) and the retry-safetybuffer_updatesmerge logic that follows it (deliver_artifact_job.rb:116-124) must not be weakened — Instagram/Route B are additional entries in the existing generic loop, not a special case that bypasses this logic. - The scheme-allowlist pattern (
isSafeLinkUrl(), §5) must be applied to any new LLM-authored URL surfaced in the admin UI, including a Route B image URL rendered as an<img src>.
9. Decisions Needed
The following are open forks this doc surfaces with a recommendation each — none of them are decided. Josh answers these in one pass, the same way og-image-facebook-scope.md §10 was resolved, and the answers get recorded here as a ## Decisions section in a follow-up edit.
- Render approach (§2): static per-beat templates, a dynamic server-rendered pipeline, or reuse/redesign of existing marketing assets? Recommendation: start with reuse/redesign — it's the only option with zero new infrastructure, and
og-card.pngproves the token-driven design pipeline already works. cache_controlpolicy (§3): override to a longer TTL for a dedicated social-card asset class, or accept frequent re-fetches on the existing 300s default? Recommendation: override — a permanent asset re-fetched every 5 minutes by every platform that caches it is needless load for no benefit.- Does Route B replace Route A for Facebook, or do they coexist per-post?
linkAttachmentand a non-emptyassetsare mutually exclusive in onecreatePostcall (confirmed,og-image-facebook-scope.md§2 and enforced live atsocial_publisher_client.rb:155), so this is a real fork with a real cost difference: coexistence means Remy (or the delivery logic) decides per-post which route to use, all-Route-B means giving up Route A's zero-infra property entirely. No recommendation given here — this is a product call, not an engineering one. - Does Instagram ship with Route B for Facebook, or after it? Given Instagram has no Route A fallback at all, shipping order affects how long Instagram stays blocked. Recommendation: Route B for Facebook first (smaller surface, reuses Route A's existing delivery/review plumbing with one new field), Instagram second (needs the
UNVERIFIEDfield contract in §4 closed first, which requires live credentials not needed for the Facebook-only Route B work). - Instagram Buffer channel and deploy wiring — shared staging/production, like X/LinkedIn/Facebook, or production-only? Two related but distinct questions, both open: (a) does Instagram use one live Buffer channel shared across environments, the same exception
og-image-facebook-scope.md§10 Decision 4 made for X/LinkedIn/Facebook? (b) separately — and this is the part earlier drafts of this doc got wrong (§6 item 5) — the existing three channels' env vars are not actually mirrored onto staging today. Onlydeploy/worker.production.yamlwiresBUFFER_PROFILE_ID_TWITTER/LINKEDIN/FACEBOOK+SOCIAL_AGGREGATOR_API_KEY;deploy/worker.staging.yamldeliberately wires none of them, per that file's own comment (Buffer has no sandbox, so a "staging" run would hit the same live channels — not a safer rehearsal). No recommendation given here for either sub-question — Josh should confirm whether Instagram follows the existing channel's shared-Buffer-account-but-production-only-deploy-wiring pattern, or something else, rather than an implementer inferring one from the other three channels' partial mirroring.
See also: docs/product/og-image-facebook-scope.md (Route A, shipped v4.9) · docs/milestones/v4.9-facebook-social-publishing.md · .claude/skills/orchestrating-social-rhythm/SKILL.md (Content Beat Framework)
Last updated: 2026-07-21