Social Media

Cross-platform social media workspace for drafting, scheduling, and publishing posts to LinkedIn, X, Mastodon, Bluesky, and YouTube.

What it does

Social Media is a bundled module for cross-platform publishing: one draft can target multiple connected accounts at once, and the module handles AI-assisted drafting, image/video media, scheduling, and direct publishing per platform. It replaces the legacy linkedin bundled module — previously connected LinkedIn accounts and posts carry over. Wired platforms are LinkedIn, X, Mastodon, Bluesky, YouTube, Facebook (Pages), and Instagram (Business/Creator) (no other platforms are implemented).

The primary UI is intentionally limited to the core review workflow: Needs review, Scheduled, and Published. Account connections remain available through the compact account-management action instead of occupying a primary workspace tab; the former dashboard, analytics preview, and full strategy editor are no longer part of the primary navigation. The weekly focus stays directly above the review queue because it is the shortest path from current intent to AI-generated drafts.

Post queues use uniform preview cards that collapse pasted line breaks and scroll independently from the workspace header. Selecting a card or creating a post opens the full composer in a large, scrollable dialog for focused single-post editing.

Opening the image dialog auto-fills the Bildbeschreibung with a short AI suggestion derived from the post (title/content), the strategy and weekly focus, and the selected style preset/styleguide style (POST suggest-image-description); changing the style re-suggests until the user edits the field, after which manual text is never replaced. The suggestion is written in the UI language and stays fully editable.

Image generation first asks the module AI to turn the user's short description and the meaning of the current post body into a focused English image prompt. Body copy is semantic input, but is not typeset or quoted into the image. The durable strategy and current weekly focus guide the subject, while configured instance-wide Canvas style settings provide the brand palette, body/heading font character, surface/table language, and logo identity. Instances without saved custom Canvas values, including styles reset to defaults, retain the previous preset art direction. If prompt refinement is unavailable, the composer uses a compact local fallback that still includes post meaning and keeps the same no-text constraint.

When additional named styleguide styles exist (Settings → Styleguide), the dialog offers a style selector: the default style stays preselected, and choosing a named style replaces the injected brand tokens (palette, type character, surfaces, logo identity) with that style's for this generation (styleId, validated server-side against the styleguide_styles table).

The visual-anchor selector defaults to no reference image. Users can explicitly choose the configured styleguide logo/image mark when it differs from Clapilot's built-in logo and can be resolved from a local public asset or uploaded data image, pick any reference-capable brand asset from the workspace styleguide library (Settings → Styleguide; sent as brandAssetId and resolved server-side against the styleguide_assets table), or use any existing image on the post as a predecessor. A selected brand asset is also described to the prompt model as an untouchable source mark. A resolved anchor is sent as source_image_path to the existing generated-images endpoint, which selects its image-edit path; provider/model selection remains unchanged. The dialog offers Editorial/Typography, Product screenshot, Abstract/Graphic, Photo/Reportage, and Illustration presets. Visible text stays forbidden by default for every preset and is enabled only when the user explicitly switches off No text in image; screenshot constraints remain independently configurable.

Settings default sends no model override and follows the global image provider/model. The model list comes from the image section of app_settings.media_model_catalog; an explicit selection forwards both provider_slug and model only for that generation and does not change the global default. Target platforms select landscape (LinkedIn/X and other feed targets), square (Instagram feed or mixed-platform posts), or portrait (Instagram-only story placements). Instagram story targets are published as STORIES; story publishing accepts one image or one video and does not fall back to the feed. The native Apple composer currently supports photo attachments and AI text drafting, but does not expose image generation.

The Needs review workspace includes a weekly focus prompt. It is stored separately from the durable content strategy: AI draft generation and agent review actions use the strategy as the long-term frame and the weekly prompt as the current, more specific emphasis. Saving an empty prompt clears that weekly focus. After saving a non-empty weekly focus, Regenerate drafts rewrites every post currently in draft or ready_for_review through the existing agent review action. The action asks for confirmation because it replaces draft titles and content. Scheduled, publishing, and published posts are never included; media and publish targets on regenerated drafts are preserved.

How to open / enable it

  • frontend: /modules/social-media (module navigation)
  • repo path: bundled-modules/social-media
  • module API proxy: /api/modules/social-media/api/*
  • scheduled publishing worker: bundled-modules/social-media/workers/post-publisher.mjs

The module UI renders natively in the app ("renderer": "react" in module.json, component src/components/modules/social-media-module.tsx) using the shared Clapilot design system and app-wide localization (socialMedia.* keys); index.html remains as the iframe fallback entry with feature parity.

The module is also available as a native Apple screen in clients/apple/ClapilotApple (Sources/Clapilot/Views/SocialMediaView.swift) for iPhone, iPad, and macOS. The native screen covers drafts/scheduled/published lists with search, a full-screen composer (content, internal title, target account selection with per-platform character limits, photo attachments, AI draft generation, publish now, schedule/unschedule, delete), per-target publish results with remote-post links, and account management (LinkedIn/YouTube OAuth via system browser, Mastodon/Bluesky token forms, disconnect; X accounts remain managed in the web settings). It talks to the same module API endpoints through the authenticated ClapilotAPI session and reports live page context with module slug social-media.

Key workflows

Connect accounts

Connected accounts are workspace-shared: every signed-in user sees all connected accounts (regardless of who connected them) and can publish through any of them. Each account exposes who connected it — the AccountView carries connectedBy (user id) and connectedByName (resolved from the users table, the user's e-mail address; empty string when unresolvable) — and any authenticated user may disconnect any account (DELETE accounts/{platform}/{id}; X remains managed in Settings). Reconnecting the same remote identity (LinkedIn member, Mastodon instance+account, Bluesky DID, YouTube channel) from a different user updates the existing shared row — including its stored tokens and the connectedBy user — instead of creating a duplicate. Posts and the content strategy are workspace-global in the same way: every signed-in user sees and can edit, approve, schedule, publish, and delete every draft and post of the instance, and the workspace shares one marketing strategy and one weekly focus. The user_id column on social_media_posts and social_media_strategies is creator/last-editor attribution only and is never used as a read or write filter. Per-user OAuth credentials in social_media_accounts stay bound to the connecting user.

PlatformConnect viaText limitMedia per post
LinkedInOAuth popup from the module (admin-configured LinkedIn OAuth app)3000up to 1 image, no video (v1)
Xnative X app connection in Settings -> App-Verbindungen280up to 4 images or 1 video
Mastodoninstance URL + personal access token500up to 4 images or 1 video
Blueskyhandle + app password (optional service URL, default https://bsky.social)300up to 4 images (max 1 MB each), no video (v1)
YouTubeGoogle OAuth popup from the module (admin-configured Google OAuth app)5000 (video description; post title becomes the video title, max 100)exactly 1 video, no images
Facebook (Pages)Meta OAuth popup from the module (admin-configured Meta app); one flow connects Pages + Instagram5000up to 4 images or 1 video
Instagram (Business/Creator)own Business-Login flow (oauth/start?platform=instagram, no Facebook Page needed); page-linked accounts are also imported by the Facebook flow22001-4 images (carousel) or 1 video (Reel); media required

Content is soft-trimmed to the strictest limit of the selected targets by the connectors; the composer shows per-platform character counters so users can adjust before publishing.

LinkedIn (admin OAuth keys required):

  • OAuth client id/secret come from app_settings.linkedin_oauth_client_id / linkedin_oauth_client_secret (App Verbindungen -> Externe Integrationen, admin-only), with LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET env fallbacks.
  • Default scopes: openid profile email w_member_social (override via LINKEDIN_OAUTH_SCOPES).
  • Accounts are stored in the existing linkedin_accounts table; accounts connected through the legacy linkedin module keep working without reconnecting. Personal and company account types are supported through oauth/start?accountType=personal|company.
  • The post composer shows every target with its account display name, avatar, LinkedIn account type (personal/company), and a distinct handle where available. The same identity is retained in publish results, so users with multiple LinkedIn profiles or company pages can verify the destination while editing, scheduling, and publishing.

X: X accounts are connected and disconnected in Settings -> App-Verbindungen (table x_user_integrations, admin OAuth keys in app_settings.x_oauth_client_id/secret). The module lists the connected X account as a publish target and deep-links to Settings for connection management; DELETE accounts/x/{id} is rejected with 400.

Mastodon: POST accounts/mastodon with { instanceUrl, accessToken, label? }. The token is verified against GET {instance}/api/v1/accounts/verify_credentials before the account is stored. Users create the token themselves in their Mastodon instance settings (read/write scope).

Bluesky: POST accounts/bluesky with { handle, appPassword, serviceUrl?, label? }. Credentials are verified via com.atproto.server.createSession; the app password (not the main account password) is stored encrypted and a fresh session is created on demand when publishing.

YouTube (admin Google OAuth keys required):

  • Reuses the global Google OAuth client from app_settings.google_oauth_client_id / google_oauth_client_secret with the scopes https://www.googleapis.com/auth/youtube.upload and https://www.googleapis.com/auth/youtube.readonly. The request explicitly disables incremental scope merging so older Workspace/Drive grants are not folded into the separately stored YouTube channel credential.
  • Connect via oauth/start?platform=youtube (popup, same completion endpoint as LinkedIn). The redirect URI <publicBaseUrl>/api/modules/social-media/api/oauth/complete must be whitelisted in the Google Cloud console OAuth client; override with SOCIAL_MEDIA_GOOGLE_REDIRECT_URI when needed.
  • The account row is stored in social_media_accounts with platform = 'youtube'; the handle shows the channel title.
  • Besides the module's Konten tab, the channel can also be connected/disconnected in Settings -> App-Verbindungen via the YouTube connection card (src/components/settings/youtube-connection-card.tsx), which uses GET accounts, GET oauth/start?platform=youtube, and DELETE accounts/youtube/{id} and shows an admin hint when the Google OAuth client is missing.
  • Publishing uploads the attached video through the YouTube Data API v3 resumable upload. The post title becomes the video title (max 100 chars), the post content becomes the description (max 5000 chars), and metadata.youtubePrivacyStatus (public | unlisted | private, default public) controls visibility. A YouTube target requires exactly one attached video (youtube_requires_video per-target error otherwise); images are not supported for YouTube.

Facebook Pages + Instagram (admin Meta app required):

  • Meta App-ID/App-Secret come from app_settings.meta_oauth_client_id / meta_oauth_client_secret (App Verbindungen -> Externe Integrationen, admin-only), with META_OAUTH_CLIENT_ID / META_OAUTH_CLIENT_SECRET env fallbacks.
  • One connect flow for Pages + page-linked Instagram: GET oauth/start?platform=facebook (meta is accepted as an alias) opens the Meta consent dialog with the scopes pages_show_list, pages_read_engagement, pages_manage_posts, instagram_basic, instagram_content_publish, and business_management. On completion the module exchanges the code for a long-lived user token, loads GET /me/accounts, and creates one facebook account row per Page plus one instagram account row per linked Instagram Business account (rows in social_media_accounts, page tokens encrypted; page tokens from a long-lived user token do not expire). Upserts are keyed workspace-wide on the Page id / IG user id.
  • The redirect URI <publicBaseUrl>/api/modules/social-media/api/oauth/complete must be whitelisted under Facebook Login -> Settings -> Valid OAuth Redirect URIs in the Meta developer console; override with SOCIAL_MEDIA_META_REDIRECT_URI when needed.
  • Facebook Login for Business (new business apps): Meta business apps created since 2023 cannot request the publishing permissions via the classic scope parameter — the consent dialog fails with "Invalid Scopes: pages_manage_posts, instagram_basic, instagram_content_publish". Fix: in the Meta developer console add the product Facebook Login for Business, create a Configuration (type "General") that includes the permissions above, and paste its Configuration ID into App Verbindungen -> Externe Integrationen -> Meta Login Config ID (app_settings.meta_login_config_id, env fallback META_LOGIN_CONFIG_ID). When set, oauth/start sends config_id instead of scope; the app must also be of type Business.
  • Meta app review: while the Meta app is in development mode, only app admins/developers/testers can connect and publish. Publishing for external users requires Meta app review approval of the requested permissions.
  • Public base URL required for media: Meta fetches post media by URL. The module serves media through signed, time-limited public links (GET media/public?path=&exp=&sig=, HMAC-SHA256, default TTL 30 min) built from app_settings.public_base_url (or PUBLIC_BASE_URL). Publishing image/video posts to Facebook or Instagram fails with a clear error when no publicly reachable base URL is configured; text-only Facebook posts work without it.
  • Instagram publishing uses the Content Publishing API (media container -> status poll -> publish; carousels for 2-4 images, Reels for video). Instagram cannot publish text-only posts — a target without media fails with the stable per-target error instagram_requires_media; other targets of the same post are unaffected.

Instagram Direkt-Login (ohne Facebook-Page):

Instagram can also be connected on its own via Meta's Business Login for Instagram — no Facebook Page required. This is a separate connect flow with its own app credentials:

  • Requirements: a professional Instagram account (Business or Creator) and the App-ID/App-Secret from the Meta app's Instagram API use case (App Verbindungen -> Externe Integrationen -> Instagram App-ID / App-Secret, app_settings.instagram_app_id / instagram_app_secret, env fallbacks INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET). These are the Instagram app credentials, not the Facebook app id/secret.
  • Connect via GET oauth/start?platform=instagram → authorize dialog on https://www.instagram.com/oauth/authorize with the scopes instagram_business_basic and instagram_business_content_publish. The redirect URI <publicBaseUrl>/api/modules/social-media/api/oauth/complete must be whitelisted in the Meta console under Instagram API -> API setup with Instagram login -> Business login settings (override with SOCIAL_MEDIA_INSTAGRAM_REDIRECT_URI).
  • On completion the module exchanges the code for a short-lived token (api.instagram.com/oauth/access_token), upgrades it to a long-lived IG user token (~60 days) (graph.instagram.com/access_token, grant_type=ig_exchange_token), loads the profile via graph.instagram.com/v21.0/me, and stores the account in social_media_accounts with session_data.login_type = "instagram_login" (token encrypted, expiry tracked).
  • Token refresh: when the stored token is within 7 days of expiry, publishing automatically refreshes it via graph.instagram.com/refresh_access_token (grant_type=ig_refresh_token) and persists the new token. Refresh only works for tokens older than 24 hours; a failed refresh is tolerated (publishing continues with the current token). Accounts whose token has fully expired show status expired and must be reconnected.
  • Coexistence with the page-based flow: both connect paths coexist. Direct-login accounts publish via graph.instagram.com with the IG user token; page-linked accounts publish via graph.facebook.com with the page token. Because the two flows use different id spaces, upserts dedupe by IG user id and by handle/username — reconnecting the same IG account through the other flow updates the existing row (the most recent connect defines the publish mode) instead of creating a duplicate.

OAuth completion flow (LinkedIn, YouTube, Facebook, Instagram)

All OAuth platforms share one completion endpoint: GET oauth/complete is a public (unauthenticated) module endpoint, allowlisted in src/app/api/modules/[slug]/api/[...endpointPath]/route.ts; the OAuth state row carries the owning user and the platform (linkedin, youtube, facebook, or instagram). On completion it returns a small HTML page that posts {"type":"social-media:oauth-complete","platform":...} (module UI) — plus, for LinkedIn only, the legacy {"type":"linkedin-oauth"} message (Settings LinkedIn connection card) — to window.opener, then closes the popup.

Module UI oauth/start popup Provider consent LinkedIn / Google / Meta oauth/complete public, allowlisted postMessage popup closes

Migration note (redirect URI): the module uses a NEW OAuth redirect URI: <publicBaseUrl>/api/modules/social-media/api/oauth/complete. This URI must be added to the authorized redirect URLs of the LinkedIn developer app; the old /api/modules/linkedin/api/oauth/complete URI is no longer used. Until the new URI is whitelisted, LinkedIn connect attempts fail at the LinkedIn authorize step. The redirect URI can be overridden explicitly with the SOCIAL_MEDIA_LINKEDIN_REDIRECT_URI env variable.

Compose a post

The composer has an explicit post-type selector (persisted as metadata.postType, values content | video; posts saved before the field existed count as video when they contain a video attachment, otherwise content):

  • Text/Bild (content): image media tools only (generate image, edit image, upload images); selectable targets are LinkedIn, X, Mastodon, and Bluesky. The YouTube chip stays visible but disabled with a hint that YouTube is video-only.
  • Video (video): video media tools only (Video-Studio import, AI video generation, upload video) with at most one video; selectable targets are X, Mastodon, and YouTube. LinkedIn and Bluesky chips stay visible but disabled (no video support in v1). The YouTube visibility select and exactly-one-video validation apply as before.

Switching the type while incompatible media or targets are still attached shows a localized conflict warning and blocks save, schedule, and publish until the user resolves it — attachments and targets are never dropped automatically.

Media flows. Media files live in the module workspace at <workspace>/social-media/media/ (workspace root from CLAPILOT_WORKSPACE_DIR / OPENCLAW_WORKSPACE_DIR, default /app/workspace), with an index.json sidecar for titles/sources.

  • Generate/edit images: the module UI calls the existing app routes POST /api/generated-images/generate and POST /api/generated-images/edit client-side (authenticated browser session), then re-uploads the resulting blob via POST media/import with kind: "upload" and source: "generated"/"edited". After a user submits the prompt, the prompt dialog closes and the operation continues as a composer background job so the user can keep editing the post. Completion uses POST posts/{id}/media to append the result atomically to the post that started the job, even if another post is selected meanwhile. Attached images can be opened in a large preview from the thumbnail.
  • Video-Studio videos: POST media/import with { kind: "video-studio", slug } copies <workspace>/video-studio/videos/<slug>.mp4 directly (shared module workspace volume). The UI picker degrades gracefully when the caller may not access the video-studio module API.
  • Provider video generation (KI): the UI calls POST /api/social-media/video-generation (see below), polls GET /api/social-media/video-generation/{id} until ready, then imports with { kind: "livestream-asset", assetId }; the handler resolves livestream_assets.media_path and copies the file. Available provider rows include xai-grok-video when the xAI Grok runtime provider has OAuth/API credentials.
  • Uploads: { kind: "upload", filename, base64, mime?, title?, source? }; base64 payloads up to 80 MB server-side (the UI caps file uploads at 50 MB). Supported types: png, jpg/jpeg, webp, gif, mp4, mov, m4v, webm.
  • GET media/file?path=media/<file> serves the binary inline (path-traversal guarded, must stay inside the module media dir). DELETE media?path= removes file + index entry and refuses with 409 while a non-posted post still references the file.

AI draft generation

POST generate with { brief, tone?, platforms?, language? } returns { title, content, hashtags[] }. The prompt targets the strictest character limit of the requested platforms and instructs the model to answer in the requested language (de/en/it, default de). Generation goes through the native ClapilotAICore runtime (/internal/responses) first and falls back to the compatibility gateway on connectivity errors — the same mechanism the legacy linkedin module used (sourceType: module_social_media_ai_draft).

Composer agent action buttons call POST posts/{id}/review-action with a client request ID. The handler atomically claims one job per post, returns an already-completed result for a repeated request ID, runs draft generation through the native runtime, and conditionally applies the result only while the claimed title, content, publish targets, and job metadata are unchanged. Media-only updates can complete independently without discarding the rewrite, while a target change cancels copy generated for the previous platform set. Agent review is available only for draft and ready_for_review posts, so scheduled or approved posts cannot silently lose their workflow state. Completed jobs move the post to ready_for_review; failures restore the pre-claim status only while that job is still the latest active claim, so a late expired request cannot settle over a replacement. Conflicting user edits cancel the late result instead of being overwritten. A failed or cancelled request ID is terminal and must be retried with a new client request ID. Every normal claim atomically requires draft or ready_for_review. For an orphaned job, or one older than the shared 15-minute stale threshold, the handler first restores the recorded review status and then performs the same guarded claim. Bulk regeneration also sends the confirmed value as expectedWeeklyPrompt, and generation uses that exact snapshot. The final post update takes a short row lock on the strategy and applies the generated result only when the stored focus still matches. A changed or cleared focus discards the model result, restores the prior post status, returns weekly_focus_changed with 409, and tells the caller to abort the remaining batch. No database lock or transaction is held while the external model runs. The composer exposes a dedicated localized restart control for that recovery path while keeping approval, scheduling, publishing, deletion, media changes, and the normal agent-action controls locked.

Schedule and publish

  • POST posts/{id}/publish publishes NOW to all pending targets (or a provided subset). Each target records posted/failed plus remoteId/remoteUrl; the post ends as posted, partial, or failed.
  • POST posts/{id}/schedule ({ scheduledFor }, future ISO timestamp, requires at least one target) and POST posts/{id}/unschedule toggle scheduled state. Scheduling also copies the timestamp into the editorial plannedFor; unscheduling keeps plannedFor, so the post stays at its campaign position.
  • Due scheduled posts are claimed with FOR UPDATE SKIP LOCKED (concurrent-worker safe) and published by:
    • the post-publisher worker (workers/post-publisher.mjs), started by entrypoint.sh. Env: CLAPILOT_SOCIAL_MEDIA_PUBLISHER_ENABLED (default true), SOCIAL_MEDIA_PUBLISHER_POLL_SECONDS (default 30, minimum 5), SOCIAL_MEDIA_PUBLISHER_BATCH (default 5, 1-50), --once / SOCIAL_MEDIA_PUBLISHER_ONCE=true for single-shot runs.
    • an opportunistic in-handler check: any handled module API request triggers a fire-and-forget publish-due run at most once per 60 seconds per process, so scheduled posts still go out in deployments without the worker.
  • POST posts/publish-due is the service/worker entry point. It is intentionally callable without user context and operates across all users.

How the agent can drive it

The module exposes a first-class native ClapilotAICore tool family (also available to Live Voice / Realtime):

  • social_media_list_accounts
  • social_media_get_weekly_prompt
  • social_media_update_weekly_prompt
  • social_media_list_posts
  • social_media_regenerate_drafts
  • social_media_get_post
  • social_media_create_draft
  • social_media_update_post
  • social_media_attach_media
  • social_media_schedule_post
  • social_media_publish_post
  • social_media_delete_post

The runtime catalog exposes these tools under the canonical social_media family. The compatibility ids social and social-media are accepted by catalog search and expansion. Agents updating the weekly focus read it back with social_media_get_weekly_prompt before confirming the mutation.

Behavior notes:

  • Target account_id may be omitted when exactly one connected account exists for a platform; the tool proxy resolves it from GET accounts.
  • social_media_list_posts returns posts in campaign order (see "Editorial order"). social_media_create_draft and social_media_update_post accept planned_for (ISO timestamp) for the editorial planning date; it needs no targets or approval, and an empty string clears it on update. Use it when the user gives a draft a date without asking to schedule publication.
  • Automated draft creation should pass a stable idempotency_key (for example release:9.3.13:x). Replaying the workflow returns that draft instead of inserting another one. Publishing X media validates account state and OAuth scopes before upload and again after token refresh. A denied media upload or tweet creation records the exact phase, required/granted/missing scopes, upstream status and request id, and tells the operator to verify the X app's Read and write permission/API tier before reconnecting. Publishing only succeeds as an agent tool after every requested target has a remote id and URL. A failed target is retried on the same post.
  • social_media_attach_media sources: generated_image (id from images_generate/images_edit), video_studio (video slug), livestream_asset (asset id from livestream_generate_video).
  • social_media_regenerate_drafts is mutation-capable and runs only after a clear user request. It pages through every draft and ready_for_review post only when a non-empty weekly focus is saved, reuses the guarded per-post review action with the confirmed focus as an expected-value guard, preserves targets/media, excludes scheduled/published posts, and reports partial failures. If the focus changes during a model call, the final compare-and-set discards that output and both UI and agent callers abort the remaining batch instead of switching to the new value.
  • x_create_post remains available for standalone X replies/quotes outside the Social Media workflow; multi-platform posting should go through the social_media_* tools.
  • When the Social Media module is open, page context activates the module action rule; a global hint routes social-posting requests to these tools from anywhere in the app.

See Agent Tool Contracts for the full contract table.

Configuration & limits

Editorial order (campaign plan)

Every post carries two independent dates:

  • scheduledFor: the publish schedule. It requires publish targets, is validated as a future timestamp, and is owned by the scheduler/publisher.
  • plannedFor: the editorial planning date. It only positions the post in the campaign order and never depends on account assignment, approval, or status, so a draft without accounts can still be placed at "11.09. 09:00". It is edited in the composer ("Redaktioneller Termin"), via POST/PATCH posts (plannedFor, null/empty clears), and by the agent (planned_for).

GET posts (and therefore the web grid, the Apple lists, and social_media_list_posts) always returns posts in campaign order, shared as POST_LIST_ORDER_BY in api/editorial-date.mjs and mirrored by compareEditorialOrder in the web client:

  1. unpublished posts with an editorial date (coalesce(scheduled_for, planned_for)), ascending: the next upcoming post is first (top-left in the grid, then row by row left to right, top to bottom);
  2. unpublished posts without any date, oldest created first;
  3. posted, partial, and failed posts, newest publication first, with their status badge visible.

updated_at is never part of the order, so editing, saving, or reloading does not reshuffle the grid. Ties on the same timestamp fall back to created_at, then id.

One-time title backfill: when migration 005_editorial_planning_date.sql is applied, the schema loader (backfillEditorialDatesFromTitles in api/schema.mjs) scans unpublished posts that have neither scheduled_for nor planned_for and whose title carries exactly one unambiguous date (11.09., 11.09.2026, 2026-09-11, optionally followed by 16:00/16 Uhr-style times). That date becomes planned_for (metadata.plannedForSource = "title"), interpreted in Europe/Berlin with 09:00 as the default time. Titles without a year resolve to the first matching day on or after the row's created_at. Titles with two dates, two-digit years (11.09.26), or invalid calendar days are skipped. Content, media, targets, schedules, and statuses are never modified.

Data model

The module owns its schema via bundled migrations (bundled-modules/social-media/migrations/, tracked in module_schema_migrations under slug social-media):

  • social_media_accounts: mastodon/bluesky/youtube accounts (platform, label, handle, avatar_url, encrypted session_data). LinkedIn stays in linkedin_accounts, X stays in x_user_integrations.
  • social_media_posts: title (internal working title), content, status (draft | scheduled | publishing | posted | partial | failed), scheduled_for, planned_for (editorial planning date, migration 005), published_at, targets (JSONB array), media (JSONB array), metadata. Rows are workspace-global; user_id records the creator for attribution and never scopes queries. The idempotency key of POST posts stays scoped to the creating user because it de-duplicates one caller's retried create request.
  • social_media_strategies: the workspace-global content strategy plus the weekly focus in metadata.weeklyPrompt. Exactly one row exists per instance, keyed by the constant scope = 'workspace'; user_id records who last saved it. Migration 004_workspace_visibility.sql collapses older per-user strategy rows into that single row by keeping the most recently updated one.
  • social_media_oauth_states: short-lived OAuth state rows (30-minute validity, opportunistic cleanup).

targets item shape: { platform, accountId, status: "pending"|"posted"|"failed", remoteId, remoteUrl, error, postedAt }. media item shape: { id, type: "image"|"video", path: "media/<file>", mime, source: "generated"|"edited"|"upload"|"video-studio"|"provider", title, thumbnailPath }.

One-time legacy import: the first time the schema comes up with an empty social_media_posts table and a legacy linkedin_posts table exists, its rows are copied over (original ids preserved, target platform linkedin with the row's account id, metadata.legacyLinkedinImport = true). Legacy posts whose schedule is already past-due are imported as draft instead of scheduled so nothing auto-publishes stale content right after the migration.

Token storage

Mastodon tokens, Bluesky app passwords, and LinkedIn/YouTube OAuth tokens are stored AES-256-GCM encrypted inside the account row's session_data. The key material chain (LINKEDIN_TOKEN_ENCRYPTION_SECRET falling back to AUTH_SECRET) is identical to the legacy linkedin module, so previously stored LinkedIn tokens keep decrypting.

Module API endpoints

Base: /api/modules/social-media/api

MethodEndpointPurpose
GEThealthmodule health
GETbootstraplegacy contract for the Settings LinkedIn connection card (linkedin account rows + oauth config flag)
GETaccountsall connected accounts across platforms plus platforms[] info (`kind: oauth
POSTaccounts/mastodonconnect a Mastodon account ({ instanceUrl, accessToken, label? })
POSTaccounts/blueskyconnect a Bluesky account ({ handle, appPassword, serviceUrl?, label? })
DELETEaccounts/{platform}/{id}disconnect an account (mastodon/bluesky/youtube rows, linkedin via linkedin_accounts; x is rejected with 400 — managed in Settings)
DELETEaccounts/{id}legacy alias: treated as a LinkedIn account id (Settings card contract)
GETposts?status=&q=&limit=&offset=list posts in campaign order (next upcoming first by coalesce(scheduled_for, planned_for), undated drafts next, posted/partial/failed last; status filter, title/content search, limit 1-200, non-negative offset)
POSTpostscreate draft ({ title?, content, targets?, media?, scheduledFor?, plannedFor? }; scheduledFor requires targets, plannedFor does not)
GETposts/{id}read one post (includes plannedFor)
PATCHposts/{id}update draft fields incl. plannedFor (null/empty clears); clearing scheduledFor reverts to draft and keeps the editorial date
DELETEposts/{id}delete post (blocked with 409 while publishing)
POSTposts/{id}/publishpublish now ({ targets? } optional subset)
POSTposts/{id}/scheduleschedule ({ scheduledFor })
POSTposts/{id}/unscheduleback to draft
POSTposts/publish-dueclaim + publish due scheduled posts across users ({ limit? }, worker/service entry)
POSTgenerateAI draft ({ brief, tone?, platforms?, language? })
GETmedialist media workspace items
POSTmedia/importimport media (`kind: upload
GETmedia/file?path=serve one media binary inline
GETmedia/public?path=&exp=&sig=public, signed media link (HMAC-SHA256 over path\nexp, expiring; used by Meta to fetch post media; tampered signature → 403, expired → 400)
DELETEmedia?path=delete a media file (409 while referenced by an unpublished post)
GET/POST`oauth/start?platform=linkedinyoutube
GEToauth/completepublic OAuth completion endpoint for LinkedIn, YouTube, and Facebook/Instagram (posts social-media:oauth-complete with the platform; LinkedIn completions additionally post the legacy linkedin-oauth message)

Implementation: bundled-modules/social-media/api/handler.mjs with per-platform connectors in bundled-modules/social-media/api/connectors/*.mjs.

App API: provider video generation

  • POST /api/social-media/video-generation with { prompt, title?, model?, provider_slug?, duration_seconds? } submits a text-to-video job through the configured livestream media-generation providers, including xAI Grok Video when selected, and returns 202 { assetId, status: "generating" }.
  • GET /api/social-media/video-generation/{id} returns { assetId, status: "generating" | "ready" | "failed", mediaPath, thumbnailPath, error }. Ready assets are imported into the post via media/import kind: "livestream-asset".

Troubleshooting

  • LinkedIn/YouTube connect fails at the provider's authorize step — the new redirect URI <publicBaseUrl>/api/modules/social-media/api/oauth/complete is not whitelisted yet in the LinkedIn developer app / Google Cloud console OAuth client (the legacy /api/modules/linkedin/... URI is no longer used).
  • The X account cannot be disconnected from the module — by design: X connections are managed in Settings -> App-Verbindungen; the module's DELETE accounts/x/{id} returns 400.
  • A YouTube target fails with youtube_requires_video — YouTube posts need exactly one attached video and no images; switch the post type to Video and attach a single video.
  • Deleting media returns 409 — the file is still referenced by a draft or scheduled post; remove the attachment (or the post) first.
  • Scheduled posts do not publish — check the post-publisher worker (CLAPILOT_SOCIAL_MEDIA_PUBLISHER_ENABLED); without it, publishing still happens opportunistically on module API traffic, at most once per 60 seconds per process.