Skip to content

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 assets field instead of linkAttachment.
  • Instagram, for any post: Buffer's live-introspected schema confirms InstagramPostMetadataInput has no linkAttachment field 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 via assets. 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.url in Buffer's schema is a public URL Buffer fetches from, and the existing google ActiveStorage service is already public: true (§3). The only real gap is cache_control policy 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 — as FocusDetailOverlay.vue, not the original ReviewQueueView.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:

  1. 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.
  2. 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/Gemfile and agent_runner/package.json, there is no headless-browser screenshot step (no Puppeteer/Playwright-as-a-service, no wkhtmltoimage, no image-rendering gem) anywhere in the stack today. This is the most flexible option and the most expensive to build and operate.
  3. 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 assets field, 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.cjsprimary/primary.light/primary.glow/secondary/accent/accent.light/destructive/gold/coach/muted color 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.vue are 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):

yaml
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: true and a public GCS bucket mean an ActiveStorage attachment's .url is directly usable as ImageAssetInput.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 in rails_api/app for a per-attachment or per-asset-class cache_control override (checked: no other cache_control reference 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_control for a new, dedicated asset class (e.g. a second google_social_cards: service entry in storage.yml with a long TTL, or an ActiveStorage service: override on the specific attachment).
    • Accept the 300s default and the resulting frequent re-fetches.

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 runSOCIAL_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 / typeStatusSource
InstagramPostMetadataInput.typePostType!confirmed NON_NULLog-image-facebook-scope.md §2 (live-verified 2026-07-19)
InstagramPostMetadataInput.shouldShareToFeedBoolean!confirmed NON_NULLog-image-facebook-scope.md §2 (live-verified 2026-07-19)
InstagramPostMetadataInput.linkAttachmentconfirmed absent — no such fieldog-image-facebook-scope.md §2/§7 (live-verified 2026-07-19) — this is why Instagram is Route-B-only
PostType enum members for InstagramUNVERIFIEDNot 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.
stickerFieldsUNVERIFIED — presence, shape, nullability all unknownNot introspected
geolocationUNVERIFIED — presence, shape, nullability all unknownNot introspected
AssetInput / ImageAssetInput aspect-ratio, dimension, format, or size constraintsUNVERIFIED — unknown whether Buffer's schema encodes these or whether they're Instagram-platform rules enforced outside GraphQLNot 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 modelUNVERIFIEDNamed 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.hasFacebook is true — a clickable link to item.socialPost.linkUrl, gated by isSafeLinkUrl() (workItem.ts), which allowlists only http:/https: schemes before the LLM-authored URL is ever used as an href. This is the same scheme-allowlist requirement og-image-facebook-scope.md §6 called for.
  • Per-channel text (ch.text) in a <pre> block, with a character counter against SOCIAL_CHANNEL_LIMITS ({ x: 280, linkedin: 3000 }) where a hard limit exists, and a plain count where it doesn't (Facebook has no hard cap, matching og-image-facebook-scope.md §6's original recommendation).
  • SOCIAL_CHANNEL_LABELS currently 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 new imageUrl (or similar) field threaded through useWorkforceBoard.ts's buildSocialPost() the same way linkUrl is today.
  • Add instagram: 'Instagram' to SOCIAL_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 into src instead of href.

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

  1. rails_api/app/services/ai/social_publisher_client.rb
    • Add 'instagram' => 'BUFFER_PROFILE_ID_INSTAGRAM' to CHANNEL_ENV_KEYS (currently x/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) raises ArgumentError if link_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 sends assets: []. Route B is the first caller that will actually populate assets with real entries — this is expected to finally exercise that guard, not something that needs to be rebuilt.
    • create_post's input: hash needs an assets: payload with real ImageAssetInput entries instead of always [], and — per Instagram's confirmed-NON_NULL fields (§4) — an Instagram branch of the metadata: key analogous to the existing metadata: { 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.
  2. rails_api/app/jobs/ai_workforce/deliver_artifact_job.rb
    • deliver_social_post (deliver_artifact_job.rb:144) currently threads link_url through for Facebook and permanently fails if facebook is requested with a blank link_url (deliver_artifact_job.rb:156-160). Route B needs the equivalent guard for an image field (e.g. image_url or 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 the buffer_updates retry-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 generic channels/content loop, not a special case.
  3. 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 include link_url whenever facebook is in channels (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 tagged Instagram as 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 way link_url was added for Facebook.
  4. BUFFER_PROFILE_ID_INSTAGRAM — new env var, same shape as BUFFER_PROFILE_ID_FACEBOOK. No Instagram channel ID has been confirmed connected in Buffer yet (unlike Facebook, which had a live channel ID by the time og-image-facebook-scope.md was written) — this needs a real Buffer-side Instagram channel connection before a value exists.
  5. deploy/worker.staging.yaml / deploy/worker.production.yamlcorrection, re-verified 2026-07-21: these are NOT mirrored. Only deploy/worker.production.yaml:169-175 carries BUFFER_PROFILE_ID_TWITTER, BUFFER_PROFILE_ID_LINKEDIN, BUFFER_PROFILE_ID_FACEBOOK, and SOCIAL_AGGREGATOR_API_KEY. deploy/worker.staging.yaml carries 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 names scripts/check-deploy-secret-drift.mjs as 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. Whether BUFFER_PROFILE_ID_INSTAGRAM follows 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.
  6. Admin dashboard — see §5 for the itemized <img> preview and label additions.
  7. Storage — see §3 for the cache_control decision and any new storage.yml service entry.
  8. CLAUDE.md's AiArtifact delivery 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, see docs/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 verified saveToDraft against 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 UNVERIFIED rows in §4 — do not build against a guessed field shape.
  • Must-verify-live list (do not trust until confirmed against real channels):
    1. Every UNVERIFIED row in §4 — re-run Buffer introspection with real credentials before writing any Instagram-specific code.
    2. The first real (non-empty) assets payload through create_post — confirm Buffer accepts the ImageAssetInput shape as constructed and that the image actually renders correctly on the receiving platform.
    3. 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 saveToDraft flow.

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 shadow autonomy (AiEmployee::SHADOW_LOCKED_ROLES includes social_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 identical createPost call 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-safety buffer_updates merge 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.

  1. 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.png proves the token-driven design pipeline already works.
  2. cache_control policy (§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.
  3. Does Route B replace Route A for Facebook, or do they coexist per-post? linkAttachment and a non-empty assets are mutually exclusive in one createPost call (confirmed, og-image-facebook-scope.md §2 and enforced live at social_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.
  4. 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 UNVERIFIED field contract in §4 closed first, which requires live credentials not needed for the Facebook-only Route B work).
  5. 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. Only deploy/worker.production.yaml wires BUFFER_PROFILE_ID_TWITTER/LINKEDIN/FACEBOOK + SOCIAL_AGGREGATOR_API_KEY; deploy/worker.staging.yaml deliberately 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

Loading…