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 scrapesog:image/og:title/og:descriptionfrom 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 onmarketing_landing/index.html(§5 item 3), so every Facebook post's preview card is content-invariant (identicalog:title/og:description/og:imageregardless 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.typeis the onlyNON_NULLfield on that input;linkAttachmenthas no!— it is optional; andassetsis a non-null list, but an empty list satisfies a non-null list requirement (the existing GraphQL client, commitd9a491949, already sendsassets: []for X/LinkedIn today, so this is a proven-valid payload shape, not a guess). Sometadata: { facebook: { type: post } }, nolinkAttachment,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 / type | Shape | Implication |
|---|---|---|
CreatePostInput.metadata | PostInputMetaData (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. |
FacebookPostMetadataInput | type: PostTypeFacebook! (post|story|reel), annotations, linkAttachment: LinkAttachmentInput, firstComment | type: post is what we want. story/reel are out of scope. |
FacebookPostMetadataInput.linkAttachment optionality | type 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 field | We 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.image | ImageAssetInput { 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. |
InstagramPostMetadataInput | type: PostType! (NON_NULL), shouldShareToFeed: Boolean! (NON_NULL), no linkAttachment field at all | Instagram 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. |
LinkedInPostMetadataInput | has linkAttachment: LinkAttachmentInput | Same 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.htmlalready ships a full OG block:og:type,og:url,og:title,og:description,og:site_name, andog:image(https://www.objectuve.com/images/screenshots/dashboard.png, declaredog: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) hasog:type,og:title,og:description,og:site_name— but noog:imageand noog:urlat 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.jsonandsrc/were confirmed to have no@vueuse/head,vue-meta, orunhead, and the app has no SSR/prerender step — it's a client-rendered SPA served from a single staticindex.html. Per §10 Decision 3, per-route SPA meta-injection is explicitly out of scope, not merely deferred: Phase 1 links only tomarketing_landingpages. - Brand tokens exist and are usable for a designed card if/when Route B is built:
design_system/tailwind-tokens.cjsdefines the color system as CSS-variable-backed Tailwind tokens (primary,primary.light,primary.glow,secondary,accent,destructive,muted) and fontsdisplay: ['Outfit', ...]/sans: ['Inter', ...]. The brand gradient (#177bbb → #05cbfc), accent orange (#f28529), and gold (#fcc419) referenced inCLAUDE.md/docs/brand/brand.mdare the CSS custom-property values behind those tokens, not literal hex constants in the Tailwind config — confirm exact hex values indesign_system/css/tokens.cssbefore 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'sgoogle:service already haspublic: trueand points atGCS_BUCKET_NAME(enkidu-storage/enkidu-storage-staging, per-environment, already isolated per the OBJ-1341 pattern used elsewhere in this repo).- Because
ImageAssetInput.urlin Buffer's schema is a public URL fetched by Buffer, not an upload target, apublic: trueGCS-backed ActiveStorage attachment'surlis 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 raisecache_controlfor 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:
rails_api/app/services/ai/social_publisher_client.rb- Add
'facebook' => 'BUFFER_PROFILE_ID_FACEBOOK'toCHANNEL_ENV_KEYS. - Add Facebook to
configured?'s OR-chain. - Committed explicitly: send
assets: []alongsidelinkAttachment, never omitassets.AssetInput's containing list isNON_NULLin Buffer's schema (§2), so omitting the key outright may be invalid — the existing client already provesassets: []is an accepted empty-list value for X/LinkedIn (commitd9a491949,create_post,assets: []in theinput:hash). The Facebook branch must follow the same pattern, not omit the key. This exact combination —linkAttachmentpresent +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
createPostinput:payload for a Facebook post (extending the shape already shipped ind9a491949'screate_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 } } } }metadatais new — every other key already exists increate_posttoday for X/LinkedIn (perd9a491949) 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
linkAttachmentand a non-emptyassetsin the same call (e.g. raise a localArgumentError), rather than relying solely on Buffer rejecting it withInvalidInputError. Build this now, but note for the implementer: it cannot trip in Phase 1 — Phase 1 never populatesassetswith 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) wiringassetsincorrectly 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).
- Add
rails_api/app/jobs/ai_workforce/deliver_artifact_job.rb- No structural change expected to
deliver_social_post— it already treats channels generically viapayload['channels']/payload['content']. The new requirement is that the artifactpayloadmust also carry the link URL Remy wants Facebook to preview (see item 3), anddeliver_social_postneeds to pass that through intocreate_scheduled_drafts(currently it only passeschannels:/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 nolink_url(or a blank one), andLinkAttachmentInput.urlisNON_NULLin 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 inspectscontent[channel]per channel and has no notion of a link-URL companion field at all. Add an explicit rule: if'facebook'is present inchannelsandpayload['link_url'](orpayload[:link_url]) is blank, treat it the same as the existing malformed-input case — route tofail_social_post_permanently(do not raise/retry; retrying won't produce a URL Remy didn't provide) — so a missinglink_urlis caught and recorded indelivery_metadata['error']before ever reaching Buffer, not discovered as a liveInvalidInputErrorafter an admin has already approved the post. This guard and the mutual-exclusivity invariant above are hard requirements — do not weaken either.
- No structural change expected to
- Two places in
rails_api/lib/tasks/ai_workforce.rakeencode 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 onlychannelsandcontent. Proposed new payload shape for a Facebook-eligible post:jsonThe prompt should instruct Remy to (a) always include a{ "channels": ["x", "linkedin", "facebook"], "content": { "x": "...", "linkedin": "...", "facebook": "..." }, "link_url": "https://www.objectuve.com/#features" }link_urlpointing at an existing objectuve.com page (marketing site or a dedicated landing page — not an in-app SPA route, per §3) wheneverfacebookis inchannels, and (b) never invent a URL — reuse the canonical CTAs already defined indocs/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_urlis required wheneverfacebookis inchannels— enforced by item 2's guard. - An implementer should grep the rake file for both spots rather than diffing only the
task_promptstring — thedescription: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-rhythmskill's five-beat Content Beat Framework as the canonical source for Remy's beats, and update the sametask_promptto reference it explicitly, dropping the "e.g." so the beat list is closed rather than illustrative. This is independent of thelink_urlchange above and is not a Phase 1 blocker — see §8.
- The employee
- 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_campaignare parsed off the landing URL byionic_frontend/src/lib/sourceAttribution.ts(captureSourceAttribution), attached to thefirst_openPostHog event and registered as super-properties + first-touch person properties byionic_frontend/src/lib/acquisitionAnalytics.ts(trackFirstOpen) — documented indocs/operations/observability.mdand exercised for launch channels indocs/marketing/launch/launch-runbook.md(utm_source = producthunt,hackernews,twitter, etc.)..claude/skills/building-acquisition-tools/references/distribution.mddocuments 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'sutm_source=facebook,utm_medium=social,utm_campaign=remy-socialare 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.htmlalready 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=facebookstill gets PostHog's built-in campaign-params parsing attached to that$autocaptureevent). Concretely: addutm_source = facebookas a filter on a PostHog Trends insight (mirroring theutm_source = producthunt/hackernewspattern already used indocs/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'sog:scrape/caching behavior (e.g. whether Facebook treatshttps://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.
- A UTM key convention already exists and should be matched, not invented:
- Testing. Read
rails_api/spec/jobs/ai_workforce/deliver_artifact_job_spec.rbbefore implementing — match its existing structure (per-scenariocontextblocks underdescribe '#perform',set_buffer_env/clear_buffer_env/stub_buffer_successhelpers, the:ai_artifact, :social_postfactory trait inrails_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 forapi.buffer.com/graphql, not the REST stub shown in the current file. New cases needed, all in thesocial_postcontext group:- Facebook link-preview payload success: channels include
'facebook', a validlink_urlpresent — assert the outgoingcreatePostinput:includes the correctmetadata.facebook.type/linkAttachment.urlshape (per item 1's payload block) and thatdelivery_metadata['buffer_updates']['facebook']is set on success. - Facebook in
channels+ blank/missinglink_url→fail_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_urlpresent +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_postwith both a link and a non-emptyassetsarray raisesArgumentError), since per item 1 there is no liveDeliverArtifactJobcode path in Phase 1 that would ever construct both together.
- Facebook link-preview payload success: channels include
BUFFER_PROFILE_ID_FACEBOOK— new env var, same shape asBUFFER_PROFILE_ID_TWITTER/BUFFER_PROFILE_ID_LINKEDIN. Verified value: the Facebook channel is already connected in Buffer (channel id6a5d1630e2638b94d7987105, organization6a5592d69375eef3ab87f081, page "Objectuve - Goal Achieving"). Per §10 Decision 4, this follows the same shared staging/production live-channel pattern asBUFFER_PROFILE_ID_TWITTER/BUFFER_PROFILE_ID_LINKEDIN.deploy/worker.staging.yaml/deploy/worker.production.yaml— mirror the exact pattern from the unmergede87b30a18commit (read directly before writing, do not reinvent):BUFFER_PROFILE_ID_TWITTER/BUFFER_PROFILE_ID_LINKEDINare plainvalue:entries (not secrets — they're channel ids),SOCIAL_AGGREGATOR_API_KEYis asecretKeyRefagainst Secret Managersocial-aggregator-api-key.BUFFER_PROFILE_ID_FACEBOOKshould be added as a third plainvalue: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).CLAUDE.mdgotcha text to update once shipped:- The
AiArtifactdelivery 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.rbheader comment quoted in item 1 above needs the same update.
- The
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'toSOCIAL_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) toSOCIAL_CHANNEL_LIMITSfor 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_urlas 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:
InstagramPostMetadataInputhas nolinkAttachmentfield at all — Route A (link-preview scraping) is structurally impossible for Instagram in this API. Every Instagram post must ship a native asset viaassets(Route B only).InstagramPostMetadataInput.typeandshouldShareToFeedare bothNON_NULL— Instagram has its own required-field shape distinct from Facebook's, plusstickerFields/geolocationconsiderations 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,shouldShareToFeedsemantics, 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+e87b30a18fromclaude/intelligent-joliot-97d258onto whatever branch this work lands on. This alone fixes the pre-existing X/LinkedIn silent-no-op gap and is valuable independent of Facebook. Ifclaude/intelligent-joliot-97d258has been deleted or merged by the time this is picked up, locate the equivalent changes on master (Ai::SocialPublisherClientGraphQL client +deploy/worker.*.yamlBuffer 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
draftand does not publish." A Facebook channel now exists (channel id6a5d1630e2638b94d7987105), 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.
- 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
- 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:imageredesign formarketing_landing(§3) is done — shipped in PR #1615 asmarketing_landing/images/social/og-card.png. Wiring it intomarketing_landing/index.html'sog:image/og:image:width/og:image:heightmeta 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-rhythmskill's five-beat Content Beat Framework as canonical; update the raketask_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 indocs/product/route-b-instagram-scope.md. - Must-verify-live list (do not trust until confirmed against real channels):
saveToDraft/mode/schedulingType/dueAtinteraction (Phase 0, above) — now unblockable. UNVERIFIED until run.- UNVERIFIED — that Facebook's scraper actually renders
marketing_landing's (fixed-dimension)og:imagecorrectly 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. - UNVERIFIED — that a
metadata.facebook.linkAttachment+ emptyassets: []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
shadowautonomy (AiEmployee::SHADOW_LOCKED_ROLESincludessocial_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 identicalcreatePostcall path and the identicalsaveToDraft: trueinvariant 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) inDeliverArtifactJob#deliver_social_postmust not be weakened by adding Facebook — Facebook is just another entry in the existingchannels/contentgeneric 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.
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-socialare approved as the values to use. These are a new convention for Facebook specifically — matching the existingutm_source/utm_medium/utm_campaignkey convention already used bysourceAttribution.ts/acquisitionAnalytics.ts, but with no pre-existing Facebook-specific values in the repo prior to this approval. No longer an open proposal.
- Measurement UTM values (§5 item 4) — OWNER-APPROVED 2026-07-19:
Is a content-invariant preview acceptable? → YES, accepted as shipped. Every Facebook link post will scrape the identical
og:title/og:description/og:imagefrom 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.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_landingpages or dedicated static landing pages, never directly into the SPA (§3).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_FACEBOOKgets the same treatment. Consequence: §5 items 6–7 use the shared-channel pattern with no staging-specific Facebook Page.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.Beat taxonomy — resolve now, or leave open? → Go with the recommendation. Adopt the
orchestrating-social-rhythmskill's five-beat Content Beat Framework as the canonical source for Remy's beats, and update the raketask_promptto 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