Skip to content

OG-Image Generation & Facebook Support for Remy — Scope Doc

Product: AI Workforce — Remy (social_publisher) Feature: Unblock Facebook (and evaluate Instagram) as delivery channels for social_post artifacts Status: SHIPPED (v4.9, 2026-07-20) — Phase 1 (Route A) implemented and merged; see §10 for the owner's decisions. Two items from this doc's own change surface are not currently true on master, both tracked as follow-ups: (1) the index.html og:image wiring named in §8 was never done; (2) the §6 admin review UI (shipped in PR #1629) was unintentionally deleted by an unrelated PR (#1638, OBJ-1562) shortly after — see v4.9 milestone narrative Tech Debt for both. Instagram + Route B (§7) remain deferred to docs/product/route-b-instagram-scope.md, unchanged. Date: 2026-07-19 Owner: Josh Lockhart


1. Executive Summary & Recommendation

Facebook is gated behind "OG-image generation" per the standing comment in Ai::SocialPublisherClient and the AiArtifact delivery gotcha in CLAUDE.md. Today (2026-07-19) that gate can finally be evaluated on real data: the live Buffer GraphQL schema (https://api.buffer.com/graphql) was introspected and the schema for FacebookPostMetadataInput, LinkAttachmentInput, and AssetInput was read directly, rather than guessing at Buffer's API shape.

Decided path (owner, 2026-07-19 — see §10 for the full decision record): Facebook ships as link-preview posts (Route A), with a content-invariant preview card, accepted as a deliberate tradeoff. Click-through is a goal. Route A0 (text-only) is not the shipping path.

  • Route A (link-preview, DECIDED, this is Phase 1): every Remy post that references an objectuve.com URL sends metadata.facebook.linkAttachment.url, and Facebook/Buffer scrapes og:image/og:title/og:description from that page. Requires zero new image-generation runtime — it reuses pages we already ship. Because Remy has no per-beat URL, every post links to one of the two canonical CTAs on marketing_landing/index.html (§5 item 3), so every Facebook post's preview card is content-invariant (identical og:title/og:description/og:image regardless of beat) — only the post text varies. This was surfaced and explicitly accepted by the owner (§10 Decision 2); the accepted downside is roughly 156 posts/year (Mon/Wed/Fri cadence) carrying an identical preview card. Do not re-litigate this — it is a considered tradeoff, not an oversight.
  • Route A0 (text-only) — demoted to a documented fallback, not the shipping path: FacebookPostMetadataInput.type is the only NON_NULL field on that input; linkAttachment has no ! — it is optional; and assets is a non-null list, but an empty list satisfies a non-null list requirement (the existing GraphQL client, commit d9a491949, already sends assets: [] for X/LinkedIn today, so this is a proven-valid payload shape, not a guess). So metadata: { facebook: { type: post } }, no linkAttachment, assets: [] is a valid, plain text-only Facebook status, with zero OG-image work and zero taxonomy dependency. This schema finding remains true and useful — keep it on hand as the fallback if link-preview is ever blocked (e.g. a Buffer/Facebook API change, a scraping outage) — but it is no longer the recommended default: click-through is a goal (§10 Decision 1), so Route A is what ships.
  • Route B (generated native image) — deferred, scoped together with Instagram (§10 Decision 5, §7): for posts with no natural landing URL, or wherever Instagram is pursued, generate and host a real image and send it via assets. This is strictly more work (rendering + storage) than Route A and is not part of Phase 1.

Route A also has the secondary, more conventional weaknesses of the link-preview format: Facebook (not us) decides when to re-scrape a cached preview (UNVERIFIED: first-post scrape/caching behavior — see §8's must-verify-live list), and preview quality is entirely a function of the target page's own og: tags. Route B has none of these constraints but costs a render pipeline and storage wiring not needed for Phase 1.

Independent of OG images, there is a pre-existing, higher-priority gap that must be fixed before any Facebook work matters: as of this branch, SOCIAL_AGGREGATOR_API_KEY / BUFFER_PROFILE_ID_* are wired in no deploy manifest (deploy/worker.{staging,production}.yaml), so Ai::SocialPublisherClient.configured? is false in every deployed environment and deliver_social_post is a silent no-op — for X and LinkedIn too, not just Facebook. A sibling, unmerged commit (e87b30a18, on top of the GraphQL rewrite d9a491949, both on branch claude/intelligent-joliot-97d258) already wires this for X + LinkedIn using the exact secretKeyRef/plain-value pattern this doc mirrors for Facebook. See §5 and §8 Phase 0 — this is a merge-order prerequisite, unchanged by this round of decisions.


2. Buffer Schema Findings (verified live, 2026-07-19)

Verified against api.buffer.com/graphql on 2026-07-19; treat as ground truth for Phase 1 rather than re-introspecting from scratch, but re-verify if Buffer versions the API or rejects a payload as constructed.

Field / typeShapeImplication
CreatePostInput.metadataPostInputMetaData (one member per platform)Metadata is optional-shaped at the container level, but FacebookPostMetadataInput.type is NON_NULL. Any Facebook post requires sending metadata: { facebook: { type: post } } at minimum — the current client sends no metadata key at all today.
FacebookPostMetadataInputtype: PostTypeFacebook! (post|story|reel), annotations, linkAttachment: LinkAttachmentInput, firstCommenttype: post is what we want. story/reel are out of scope.
FacebookPostMetadataInput.linkAttachment optionalitytype carries ! (NON_NULL); linkAttachment carries no ! — it is optional. assets is a non-null list, but [] is a valid non-null list.The only mandatory piece of a Facebook post is metadata: { facebook: { type: post } } — not a link, not an image. metadata: { facebook: { type: post } } + no linkAttachment + assets: [] is a valid text-only post. This is Route A0 (§1) — a documented fallback, distinct from Route A (link-preview, decided) and Route B (generated image, deferred).
LinkAttachmentInput{ url: String! }URL is the only fieldWe cannot pass a title/description/image override through Buffer's link attachment — whatever preview appears is whatever the target page's own og: tags say. This is Route A.
linkAttachment doc string"Mutually exclusive with a non-empty assets array — input providing both is rejected."Route A and Route B cannot be combined in one createPost call. Pick one per post.
AssetInput.imageImageAssetInput { url: String!, thumbnailUrl, metadata }url is described as "URL to the file source" — Buffer pulls the image from a URL we host. There is no Buffer-hosted multipart upload step. This is the single most important finding: existing ActiveStorage-on-GCS public URLs are sufficient, no new upload infrastructure needed.
InstagramPostMetadataInputtype: PostType! (NON_NULL), shouldShareToFeed: Boolean! (NON_NULL), no linkAttachment field at allInstagram cannot use Route A — there is no link-preview path for Instagram in this schema. Instagram is image-mandatory (Route B only) and has its own required fields (shouldShareToFeed, plus stickerFields/geolocation considerations for Reels-adjacent types). See §7 — out of scope for this doc.
LinkedInPostMetadataInputhas linkAttachment: LinkAttachmentInputSame Route-A shape already available for LinkedIn if ever wanted — not required by this scope (LinkedIn already ships without it).

3. Where would an OG image actually come from?

The task named three options: static per-post-type templates, dynamic server-rendered images from post text + design tokens, or reusing marketing assets. Given the Route A decision, the real question for Phase 1 is what og: tags our own pages serve today, not how to generate a new image. The three relevant HTML entry points and the design-token/brand sources were read directly rather than assumed.

What exists today:

  • marketing_landing/index.html already ships a full OG block: og:type, og:url, og:title, og:description, og:site_name, and og:image (https://www.objectuve.com/images/screenshots/dashboard.png, declared og:image:width=390 / og:image:height=844). That image is a portrait phone screenshot (390×844), not Facebook/Buffer's recommended landscape OG dimensions (1200×630) — Facebook will still render it, but cropped/letterboxed, not as a designed social card. marketing_landing/images/screenshots/ has ~15 more phone-screenshot PNGs/WEBPs (dashboard, goals, communities, achievements, etc.) in the same portrait aspect ratio; none are landscape social-card assets. This 1200×630 landscape redesign is now a settled, required dependency of Phase 1 (previously contingent on the Q1 decision — that contingency is resolved); only who does the design work remains a scheduling detail.
  • ionic_frontend/index.html (the app SPA shell) has og:type, og:title, og:description, og:site_namebut no og:image and no og:url at all. Any Remy post linking into the app itself (e.g. a public profile at /u/:username, a community page) would scrape this file's tags today and get no image, just the generic app title/description.
  • No per-route meta-tag mechanism exists in the app, and none is being built. ionic_frontend/package.json and src/ were confirmed to have no @vueuse/head, vue-meta, or unhead, and the app has no SSR/prerender step — it's a client-rendered SPA served from a single static index.html. Per §10 Decision 3, per-route SPA meta-injection is explicitly out of scope, not merely deferred: Phase 1 links only to marketing_landing pages.
  • Brand tokens exist and are usable for a designed card if/when Route B is built: design_system/tailwind-tokens.cjs defines the color system as CSS-variable-backed Tailwind tokens (primary, primary.light, primary.glow, secondary, accent, destructive, muted) and fonts display: ['Outfit', ...] / sans: ['Inter', ...]. The brand gradient (#177bbb → #05cbfc), accent orange (#f28529), and gold (#fcc419) referenced in CLAUDE.md/docs/brand/brand.md are the CSS custom-property values behind those tokens, not literal hex constants in the Tailwind config — confirm exact hex values in design_system/css/tokens.css before building any template that hardcodes color.

What this means for cost:

  • Route A (Phase 1) is cheapest for marketing_landing (already has a real og:image, just needs the 1200×630 landscape asset instead of the portrait screenshot) and for any future dedicated landing page we choose to build for Remy to link to.
  • Route A links only to marketing_landing pages or dedicated static landing pages, never directly into the SPA (§10 Decision 3) — this keeps Route A a docs/markup change rather than an SSR project.
  • The three template options from the task (static per-post-type templates / dynamic render from post text / reuse marketing assets) are all Route B options and are deferred to the Route B + Instagram follow-up (§7, §8). "Reuse marketing assets" is the cheapest of the three (redesign the existing dashboard screenshot into a proper 1200×630 landscape card using the tokens above) versus a dynamic text-to-image renderer, which is real net-new infra (a render service or headless-browser screenshot step) not currently in the stack.

4. Storage & Serving (Route B only)

Recommendation: ActiveStorage on the existing google service (rails_api/config/storage.yml) — no new infrastructure.

Reasoning:

  • storage.yml's google: service already has public: true and points at GCS_BUCKET_NAME (enkidu-storage / enkidu-storage-staging, per-environment, already isolated per the OBJ-1341 pattern used elsewhere in this repo).
  • Because ImageAssetInput.url in Buffer's schema is a public URL fetched by Buffer, not an upload target, a public: true GCS-backed ActiveStorage attachment's url is directly usable as-is. No presigned-upload flow, no multipart step, no new bucket, no new service account permission beyond what ActiveStorage already has.
  • One concrete follow-up: cache_control: "public, max-age=300" (5 minutes) is tuned for the app's existing use of this service (e.g. user photo uploads that might change), not for a permanent social-card asset a CDN/Buffer/Facebook may cache and re-fetch. If Route B ships, either raise cache_control for this specific asset class or accept that Buffer/Facebook will re-fetch it frequently — call this out explicitly rather than silently reusing the 300s default for social images.

5. Change Surface (itemized, Phase 1 — Route A)

Prerequisite (blocking, not part of this doc's scope to redo): this branch's Ai::SocialPublisherClient is still the stale REST client (api.bufferapp.com/1/, verified live to 401 against modern Buffer personal-access tokens). The real GraphQL rewrite is commit d9a491949 on branch claude/intelligent-joliot-97d258, with the deploy-manifest wiring in a follow-up commit e87b30a18 on the same branch. Facebook work must be built on top of the GraphQL client, not the REST one — merge that branch (or cherry-pick those two commits) before starting Phase 1 implementation.

Once based on the GraphQL client, the itemized surface is:

  1. rails_api/app/services/ai/social_publisher_client.rb
    • Add 'facebook' => 'BUFFER_PROFILE_ID_FACEBOOK' to CHANNEL_ENV_KEYS.
    • Add Facebook to configured?'s OR-chain.
    • Committed explicitly: send assets: [] alongside linkAttachment, never omit assets. AssetInput's containing list is NON_NULL in Buffer's schema (§2), so omitting the key outright may be invalid — the existing client already proves assets: [] is an accepted empty-list value for X/LinkedIn (commit d9a491949, create_post, assets: [] in the input: hash). The Facebook branch must follow the same pattern, not omit the key. This exact combination — linkAttachment present + assets: [] — is flagged in §8's must-verify-live list (item 3) as UNVERIFIED until the Phase 0 live run confirms it against a real channel.
    • Full createPost input: payload for a Facebook post (extending the shape already shipped in d9a491949's create_post, so an implementer doesn't have to reassemble it from §2's table plus prose):
      ruby
      {
        channelId: channel_id,
        text: text,
        schedulingType: 'automatic',
        mode: 'customScheduled',
        dueAt: scheduled_at.iso8601,
        assets: [],
        saveToDraft: true,
        aiAssisted: true,
        metadata: {
          facebook: {
            type: 'post',
            linkAttachment: { url: link_url }
          }
        }
      }
      metadata is new — every other key already exists in create_post today for X/LinkedIn (per d9a491949) and is unchanged for Facebook.
    • Mutual-exclusivity guard, forward-defense only — unreachable in Phase 1: the client should still assert locally that a channel never carries both linkAttachment and a non-empty assets in the same call (e.g. raise a local ArgumentError), rather than relying solely on Buffer rejecting it with InvalidInputError. Build this now, but note for the implementer: it cannot trip in Phase 1 — Phase 1 never populates assets with real entries (always []), so there is no live code path that could hit both conditions today. It exists purely as forward-defense against the Route B follow-up (§7, §8) wiring assets incorrectly later, once that work starts sending real image assets through the same client.
    • Update the header comment (currently: "Channel scope: X + LinkedIn only. Instagram/Facebook are deferred (need OG-image assets not built here) — do not add profile-id lookups for them.") to reflect Facebook (Route A) as in scope and Instagram as still explicitly out (§7).
  2. rails_api/app/jobs/ai_workforce/deliver_artifact_job.rb
    • No structural change expected to deliver_social_post — it already treats channels generically via payload['channels']/payload['content']. The new requirement is that the artifact payload must also carry the link URL Remy wants Facebook to preview (see item 3), and deliver_social_post needs to pass that through into create_scheduled_drafts (currently it only passes channels:/content:).
    • New required guard, same failure branch as the existing blank-content case: the prompt in item 3 is an instruction, not enforcement — Remy can still emit channels: [..., "facebook"] with no link_url (or a blank one), and LinkAttachmentInput.url is NON_NULL in Buffer's schema, so an unguarded payload would fail at the Buffer API call, post-approval, in production — not at draft time. deliver_social_post's current blank check (channels.blank? || channels.all? { |ch| content[ch].blank? && content[ch.to_sym].blank? }) only inspects content[channel] per channel and has no notion of a link-URL companion field at all. Add an explicit rule: if 'facebook' is present in channels and payload['link_url'] (or payload[:link_url]) is blank, treat it the same as the existing malformed-input case — route to fail_social_post_permanently (do not raise/retry; retrying won't produce a URL Remy didn't provide) — so a missing link_url is caught and recorded in delivery_metadata['error'] before ever reaching Buffer, not discovered as a live InvalidInputError after an admin has already approved the post. This guard and the mutual-exclusivity invariant above are hard requirements — do not weaken either.
  3. Two places in rails_api/lib/tasks/ai_workforce.rake encode the current two-channel scope — both need updating, not just one:
    • The employee description: field (~line 121): "Drafts scheduled social posts (X, LinkedIn) from product milestones and..." — needs "Facebook" added.
    • The task_prompt (~line 132) — currently instructs Remy to emit only channels and content. Proposed new payload shape for a Facebook-eligible post:
      json
      {
        "channels": ["x", "linkedin", "facebook"],
        "content": { "x": "...", "linkedin": "...", "facebook": "..." },
        "link_url": "https://www.objectuve.com/#features"
      }
      The prompt should instruct Remy to (a) always include a link_url pointing at an existing objectuve.com page (marketing site or a dedicated landing page — not an in-app SPA route, per §3) whenever facebook is in channels, and (b) never invent a URL — reuse the canonical CTAs already defined in docs/brand/social-media-inventory.md (https://www.objectuve.com/#features, #connect) unless the beat maps to a more specific existing page (with UTM params appended — see the Measurement item below). Character-limit guidance should gain a Facebook entry (Facebook has no hard character cap, but keep the same 2–3 sentence brand-voice constraint as X/LinkedIn). link_url is required whenever facebook is in channels — enforced by item 2's guard.
    • An implementer should grep the rake file for both spots rather than diffing only the task_prompt string — the description: field is easy to miss since it isn't referenced anywhere else in this doc.
    • Separate, parallel work item (§10 Decision 6): fix the beat taxonomy. Adopt the orchestrating-social-rhythm skill's five-beat Content Beat Framework as the canonical source for Remy's beats, and update the same task_prompt to reference it explicitly, dropping the "e.g." so the beat list is closed rather than illustrative. This is independent of the link_url change above and is not a Phase 1 blocker — see §8.
  4. Measurement — how click-through gets attributed and reviewed. Decision 1 (§10) made click-through the stated goal; without measurement the goal is unfalsifiable — nobody would ever know whether 156 identical-card posts/year worked. The repo was checked before proposing anything:
    • A UTM key convention already exists and should be matched, not invented: utm_source / utm_medium / utm_campaign are parsed off the landing URL by ionic_frontend/src/lib/sourceAttribution.ts (captureSourceAttribution), attached to the first_open PostHog event and registered as super-properties + first-touch person properties by ionic_frontend/src/lib/acquisitionAnalytics.ts (trackFirstOpen) — documented in docs/operations/observability.md and exercised for launch channels in docs/marketing/launch/launch-runbook.md (utm_source = producthunt, hackernews, twitter, etc.). .claude/skills/building-acquisition-tools/references/distribution.md documents the value convention: utm_source ∈ {twitter, instagram, reddit, producthunt, newsletter, direct, ...}, utm_medium ∈ {social, community, paid, email, referral}, utm_campaign = a slug. No existing Facebook-specific values were found in the repo — this doc's utm_source=facebook, utm_medium=social, utm_campaign=remy-social are a new convention for Facebook specifically, matching the existing key convention (utm_source/utm_medium/utm_campaign) but introducing new values not previously used anywhere in the repo. OWNER-APPROVED 2026-07-19 (see §10, recorded as a sub-line under Decision 1) — no longer a pending proposal. Example: link_url: "https://www.objectuve.com/#features?utm_source=facebook&utm_medium=social&utm_campaign=remy-social".
    • Where it's reviewed: marketing_landing/index.html already loads PostHog (project key embedded in the page, capture_pageview: false, but autocapture is left at its default — not explicitly disabled — so a click on a CTA while the URL carries ?utm_source=facebook still gets PostHog's built-in campaign-params parsing attached to that $autocapture event). Concretely: add utm_source = facebook as a filter on a PostHog Trends insight (mirroring the utm_source = producthunt / hackernews pattern already used in docs/marketing/launch/launch-runbook.md), and review it at the same cadence as the existing weekly PostHog pull already named in this same rake file (ai_workforce.rake ~line 78–88, "Pulls weekly product analytics from PostHog") — no new dashboard infrastructure, just a new filtered insight reviewed on the existing weekly cadence.
    • UNVERIFIED — flagging, not asserting either way: whether appending ?utm_... to the canonical CTA anchor changes Facebook's og: scrape/caching behavior (e.g. whether Facebook treats https://www.objectuve.com/#features?utm_source=facebook... as a distinct URL from the bare anchor for scrape-caching purposes) has not been confirmed. If it does, each UTM-tagged post could scrape fresh rather than hitting a warm Facebook-side cache — plausible either way, not confirmed. Check this against the Phase 0 live run alongside the other must-verify-live items (§8).
    • UNVERIFIED / speculative, do not treat as confirmed: posting the same canonical URL ~3x/week for a year (per Decision 2's accepted ~156 posts/year) is a distinct pattern from single-post OG-scrape caching — repeated identical outbound links could draw reach throttling or duplicate-link handling from Facebook's ranking systems, separate from the scrape-caching question above. No source is cited for this; it is not an assertion that Facebook penalizes it. Noting it so that low measured reach during Phase 1 monitoring isn't misdiagnosed as a delivery bug when checking the insight above.
  5. Testing. Read rails_api/spec/jobs/ai_workforce/deliver_artifact_job_spec.rb before implementing — match its existing structure (per-scenario context blocks under describe '#perform', set_buffer_env/clear_buffer_env/stub_buffer_success helpers, the :ai_artifact, :social_post factory trait in rails_api/spec/factories/ai_artifacts.rb) rather than introducing a new pattern. Note: the spec file as it exists on this branch still stubs the legacy REST endpoint (api.bufferapp.com/1/updates/create.json) because the GraphQL rewrite (d9a491949) hasn't merged yet (§8 Phase 0) — once merged, mirror whatever stub shape that commit's own spec updates use for api.buffer.com/graphql, not the REST stub shown in the current file. New cases needed, all in the social_post context group:
    • Facebook link-preview payload success: channels include 'facebook', a valid link_url present — assert the outgoing createPost input: includes the correct metadata.facebook.type/linkAttachment.url shape (per item 1's payload block) and that delivery_metadata['buffer_updates']['facebook'] is set on success.
    • Facebook in channels + blank/missing link_urlfail_social_post_permanently, not retried (mirrors the existing "requesting empty channels" / "all channels have blank content" permanent-failure contexts already in the file, e.g. around line 458).
    • link_url present + assets: [] → success (confirms the committed-explicit shape from item 1, not just that Buffer accepts it — see the UNVERIFIED note there for the live-channel counterpart of this case).
    • The mutual-exclusivity guard from item 1 — reachable only as a unit-level assertion on the client method directly (e.g. calling create_post with both a link and a non-empty assets array raises ArgumentError), since per item 1 there is no live DeliverArtifactJob code path in Phase 1 that would ever construct both together.
  6. BUFFER_PROFILE_ID_FACEBOOK — new env var, same shape as BUFFER_PROFILE_ID_TWITTER/BUFFER_PROFILE_ID_LINKEDIN. Verified value: the Facebook channel is already connected in Buffer (channel id 6a5d1630e2638b94d7987105, organization 6a5592d69375eef3ab87f081, page "Objectuve - Goal Achieving"). Per §10 Decision 4, this follows the same shared staging/production live-channel pattern as BUFFER_PROFILE_ID_TWITTER/BUFFER_PROFILE_ID_LINKEDIN.
  7. deploy/worker.staging.yaml / deploy/worker.production.yaml — mirror the exact pattern from the unmerged e87b30a18 commit (read directly before writing, do not reinvent): BUFFER_PROFILE_ID_TWITTER/BUFFER_PROFILE_ID_LINKEDIN are plain value: entries (not secrets — they're channel ids), SOCIAL_AGGREGATOR_API_KEY is a secretKeyRef against Secret Manager social-aggregator-api-key. BUFFER_PROFILE_ID_FACEBOOK should be added as a third plain value: entry ("6a5d1630e2638b94d7987105") in the same block, on both staging and production (that commit's stated rationale for shared staging/production Buffer channels — "Buffer has no sandbox... blast radius of a staging run is a deletable draft" — applies identically to Facebook, per §10 Decision 4).
  8. CLAUDE.md gotcha text to update once shipped:
    • The AiArtifact delivery gotcha currently reads: "draft_post/code_patch → GitHub PR, email_template → Mailtrap sandbox, report → Slack + docs, recommendation → docs, social_post → Buffer scheduled drafts (Ai::SocialPublisherClient, X + LinkedIn only — Instagram/Facebook are deferred behind OG-image generation, do not add profile-id lookups for them)." — the parenthetical needs to become "X, LinkedIn, and Facebook (link-preview only); Instagram remains deferred, see docs/product/og-image-facebook-scope.md" (or equivalent), once Phase 1 ships.
    • The social_publisher_client.rb header comment quoted in item 1 above needs the same update.

6. Admin Review Queue Implications

admin_dashboard/src/views/ReviewQueueView.vue's social_post branch (the only admin file that is social_post-aware) currently renders, per channel in payload.channels/payload.content: a <label> with the channel's display name (SOCIAL_CHANNEL_LABELS, currently { x: 'X', linkedin: 'LinkedIn' }), a read-only <pre> block of the post text, and a live character counter against SOCIAL_CHANNEL_LIMITS (currently { x: 280, linkedin: 3000 }) with an "over limit" flag. It explicitly does not allow inline editing ("Social posts can't be edited inline — approve or reject as-is.").

For Facebook to be reviewable in the same UI:

  • Add facebook: 'Facebook' to SOCIAL_CHANNEL_LABELS. Facebook has no hard character cap in the same sense as X/LinkedIn — either add a generous soft limit (e.g. 2000, matching Buffer's practical UI guidance) to SOCIAL_CHANNEL_LIMITS for consistent counter rendering, or special-case Facebook to render the counter without an "over limit" state.
  • New requirement not currently handled anywhere in this file: an admin approving a Facebook link-preview post needs to see what will actually render as the preview — today the component only shows raw post text, with no image/link-preview surface at all. At minimum, render the link_url as a visible link so an admin can click through and see the live OG scrape themselves (Facebook's own scraper result isn't something we can reliably reproduce client-side without calling a third-party OG-scrape API, which is out of scope here). If the Route B + Instagram follow-up ships, this view will need an actual <img> preview of the hosted asset — a materially bigger addition than the link-URL case.

7. Instagram — Explicitly OUT of Scope for This Doc

Instagram is not trivially covered by the Facebook (Route A) work, for schema reasons verified directly against Buffer's live GraphQL API:

  • InstagramPostMetadataInput has no linkAttachment field at all — Route A (link-preview scraping) is structurally impossible for Instagram in this API. Every Instagram post must ship a native asset via assets (Route B only).
  • InstagramPostMetadataInput.type and shouldShareToFeed are both NON_NULL — Instagram has its own required-field shape distinct from Facebook's, plus stickerFields/geolocation considerations for certain post types that this doc has not investigated.
  • Per §10 Decision 5, Instagram is scoped together with Route B in a single follow-up doc (not as a separate, standalone pass) — they share the same "generate and host a real image" dependency. See docs/product/route-b-instagram-scope.md, which covers the reminder-based publishing model, shouldShareToFeed semantics, and Instagram-specific asset requirements (square/portrait aspect ratio constraints, etc.) alongside Route B's render/storage design. Do not fold Instagram into the Facebook Phase 1 work.

8. Phase Breakdown & Dependencies

  • Phase 0 (prerequisite, blocking everything below): merge the GraphQL client rewrite. Cherry-pick or merge d9a491949 + e87b30a18 from claude/intelligent-joliot-97d258 onto whatever branch this work lands on. This alone fixes the pre-existing X/LinkedIn silent-no-op gap and is valuable independent of Facebook. If claude/intelligent-joliot-97d258 has been deleted or merged by the time this is picked up, locate the equivalent changes on master (Ai::SocialPublisherClient GraphQL client + deploy/worker.*.yaml Buffer env wiring) rather than relying on these SHAs resolving.
    • Must verify live, first thing after Phase 0 lands, before trusting anything downstream — UNVERIFIED: the GraphQL client's own code comment flags this as unverified — "the saveToDraft/mode/schedulingType/dueAt interaction has not been exercised against a live channel — the Buffer org has zero channels connected... The first live run must confirm the post lands as draft and does not publish." A Facebook channel now exists (channel id 6a5d1630e2638b94d7987105), so this verification is finally possible — do it against Facebook (or X/LinkedIn, whichever channel is live first) as the very first live run, before either Phase 1 or continued X/LinkedIn delivery is trusted in production. This is a correctness gate, not a nice-to-have: an unverified assumption here risks accidentally auto-publishing a live post.
  • Phase 1 (Route A — Facebook link-preview): the whole Facebook feature, decided. Depends on Phase 0 only.
    • Change surface: §5 items 1–8.
    • The 1200×630 landscape og:image redesign for marketing_landing (§3) is done — shipped in PR #1615 as marketing_landing/images/social/og-card.png. Wiring it into marketing_landing/index.html's og:image/og:image:width/og:image:height meta tags is Phase 1b (OBJ-1536) engineering work, not yet done.
    • Does not require any image-generation work, and does not depend on the beat-taxonomy fix below — content-invariance was accepted (§10 Decision 2), so there is no per-theme section to gate on. Route A0 (text-only, §1, §2) remains available as a fallback if link-preview is ever blocked, but is not part of this phase's shipping path.
  • Beat taxonomy fix (§10 Decision 6) — independent, parallel work, NOT a Phase 1 blocker. Adopt the orchestrating-social-rhythm skill's five-beat Content Beat Framework as canonical; update the rake task_prompt (§5 item 3) to reference it explicitly and drop the "e.g." so the beat list is closed rather than illustrative. This is worth doing regardless of Facebook — it fixes a latent gap in Remy's existing behavior — but explicitly does not sequence before or block Phase 1. It matters again only if the middle-ground per-theme-section option (rejected for now, §10 Decision 2) is revisited later.
  • Route B + Instagram (deferred, single follow-up doc, §10 Decision 5): generate and host a real per-post image via assets, scoped together with Instagram support since both need the same render/storage dependency. Requires: an image template/render decision (§3 — reusing/redesigning existing marketing assets is the cheapest of the three options investigated), ActiveStorage-on-GCS wiring per §4, and admin preview support per §6. Not scheduled as part of this doc — scoped in docs/product/route-b-instagram-scope.md.
  • Must-verify-live list (do not trust until confirmed against real channels):
    1. saveToDraft/mode/schedulingType/dueAt interaction (Phase 0, above) — now unblockable. UNVERIFIED until run.
    2. UNVERIFIED — that Facebook's scraper actually renders marketing_landing's (fixed-dimension) og:image correctly on first post: Facebook caches scrapes aggressively and has its own debugger (Sharing Debugger) for forcing a re-scrape; the first real post should be checked there before assuming the preview looks right.
    3. UNVERIFIED — that a metadata.facebook.linkAttachment + empty assets: [] payload is accepted as constructed: the schema description says these are mutually exclusive when assets is non-empty, but this has not been tested end-to-end against Buffer's Facebook path specifically (only introspected).

9. Hard Constraints Restated (do not weaken)

  • Remy remains hard-capped at shadow autonomy (AiEmployee::SHADOW_LOCKED_ROLES includes social_publisher) — every post, on every channel including Facebook, requires admin approval via the Review Queue. This doc proposes no autonomy change.
  • Buffer entries must remain scheduled drafts (saveToDraft: true) — never immediate publish. Facebook uses the identical createPost call path and the identical saveToDraft: true invariant as X/LinkedIn; no channel-specific override should be introduced.
  • The delivered_at-iff-all-channels-succeeded invariant and the retry-safety merge logic (delivery_metadata['buffer_updates'], never re-posting an already-succeeded channel) in DeliverArtifactJob#deliver_social_post must not be weakened by adding Facebook — Facebook is just another entry in the existing channels/content generic loop, not a special case that bypasses this logic.

10. Decisions (resolved 2026-07-19, owner: Josh Lockhart)

The owner answered all the open questions this doc previously raised. Recorded below with attribution, what was chosen, and the consequence. These are settled — do not re-litigate.

  1. Is click-through actually a goal for Remy's Facebook presence?YES, click-through is a goal. Consequence: Route A0 (text-only) is not the chosen shipping path; Facebook link posts with a preview card are in scope (Phase 1, Route A).

    • Measurement UTM values (§5 item 4) — OWNER-APPROVED 2026-07-19: utm_source=facebook, utm_medium=social, utm_campaign=remy-social are approved as the values to use. These are a new convention for Facebook specifically — matching the existing utm_source/utm_medium/utm_campaign key convention already used by sourceAttribution.ts/acquisitionAnalytics.ts, but with no pre-existing Facebook-specific values in the repo prior to this approval. No longer an open proposal.
  2. Is a content-invariant preview acceptable?YES, accepted as shipped. Every Facebook link post will scrape the identical og:title/og:description/og:image from a canonical marketing CTA anchor; only the post text varies per beat. This overrides this doc's prior recommendation (which favored resolving the beat taxonomy and defaulting to Route A0 in the meantime) — that recommendation is no longer live; this was a considered tradeoff the owner weighed and accepted, not an oversight. Accepted downside, for the record: roughly 156 posts/year (Mon/Wed/Fri cadence) will carry an identical preview card. Consequence: the "quiet-collapse" failure mode this doc previously spent a long paragraph on — the middle-ground option of N hand-authored per-theme landing sections with a silent fallback when a beat doesn't map to one — largely evaporates for Phase 1 as decided. With no per-theme sections, every post uses the canonical CTA anchor by design: there is no fallback to silently fire and no drift to detect. That reasoning remains on record as the justification for why the middle ground was not chosen now — it becomes relevant again only if the middle ground is revisited after the Decision 6 taxonomy fix lands, not as an active risk to Phase 1.

  3. Marketing-site links only, or per-route SPA meta-injection?Marketing-site links are fine. Per-route SPA meta-injection is explicitly OUT of scope (not merely deferred) — no SSR/edge-function work will be built for this. Consequence: Route A links only to marketing_landing pages or dedicated static landing pages, never directly into the SPA (§3).

  4. Facebook staging channel — shared or dedicated?Share one live channel across staging and production, the same exception already documented for X/LinkedIn in commit e87b30a18. BUFFER_PROFILE_ID_FACEBOOK gets the same treatment. Consequence: §5 items 6–7 use the shared-channel pattern with no staging-specific Facebook Page.

  5. Instagram + Route B — scope together, or separately?YES, scope together. Route B and Instagram will be written up as a single follow-up doc, since they share the generate-and-host-an-image dependency. Consequence: §7 and §8's Route B section point at one future doc, not two — see docs/product/route-b-instagram-scope.md.

  6. Beat taxonomy — resolve now, or leave open?Go with the recommendation. Adopt the orchestrating-social-rhythm skill's five-beat Content Beat Framework as the canonical source for Remy's beats, and update the rake task_prompt to reference it explicitly, dropping the "e.g." so the list is closed rather than illustrative. Consequence: this is its own work item (§5 item 3, §8), worth doing regardless of Facebook, and explicitly does not block or gate Phase 1 (Decision 2 already resolved the content-invariance question independent of it).


Last updated: 2026-07-21

Loading…