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.
Review, scheduled, and published queues show the first attached image beside the post text. Previews preserve
the full image aspect ratio and use authenticated module media loading in the web, bundled, iOS, and macOS
clients. Text-only and video-only posts keep their text layout. This uses the existing post media data and
media-file endpoint; agent tools and API contracts are unchanged.
Platform accounts indicate selection with a blue outline on a neutral surface. Instagram placement uses a Feed/Story segmented control. Segmented controls throughout Clapilot use a white shell without an outer border. Draft with AI is a single button below the main Text field: write a brief or existing draft there, then click the button to replace it with a generated post. The separate brief and tone inputs are removed; generation uses neutral tone plus the configured strategy and selected target platforms. Errors preserve the original input, and a late response never overwrites text edited while the request runs. The same direct action is available in the bundled module and native iOS/macOS editors.
Draft and image-prompt generation use isolated native-runtime requests with tools and stored conversation
history disabled. They use the supported system channel and allow up to 90 seconds for completion. The
generation request returns text to the editor; it does not create or publish another post through agent tools.
Below the image description, Generate prompt from post creates an editable AI suggestion from the
current post title/content, strategy, weekly focus, and selected style (POST suggest-image-description).
It runs only on request, in the UI language. Typing while it runs protects those edits from a late response.
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 (POST generate-image-prompt). The image is complementary to the
post: the art director must identify the one concrete point the post makes and stage exactly one tangible,
post-specific scene, object, or metaphor around it. Generic visuals (flowcharts, node graphs, pipelines,
connected boxes, forms, dashboards, gears, lightbulbs, and similar clichés) are explicitly banned, and body copy
is semantic input only, never typeset or quoted into the image. Both the suggested description and the final
prompt follow a legibility rule: the dominant subject must be a literal object, place, or activity from the
post's own domain so a viewer can guess the topic without the caption (a post about self-hosted models shows a
real compact server in the company's own room, not trays or tiles as metaphors); a central contrast in the post
(local vs. external, before vs. after) is shown in one frame; screens stay secondary and unreadable because the
image carries no text or UI. The durable strategy and current weekly focus guide the subject.
The styleguide always applies. The runtime injects the resolved default styleguide (Settings → Styleguide; the Clapilot defaults when nothing else is configured) and the prompt builder turns it into binding art direction: page/surface colors dominate, the deep action color carries the main subject, the primary accent is a small controlled highlight, off-palette hues (orange, beige, amber, yellow, warm brown, sage/olive green, teal, saturated gradients, dark backgrounds) are excluded, geometry follows the configured corner radius, and the configured type families shape the composition without rendering text.
Include logo (default on) attaches the brand logo as the first reference image: a reference-capable logo
asset from the styleguide asset library wins, otherwise the styleguide logo (default Clapilot logo included) is
materialized under <workspace>/.clapilot/brand-assets/. The prompt instructs the model to place that exact logo
once, small and unaltered, as a discreet corner signature. The composer resolves the logo from the refined prompt
response (brandLogo) or, when AI refinement is unavailable, from GET brand-reference so the local fallback
prompt keeps the logo too. Allow text in image is off by default; the post copy is never rendered into the
image, and UI mockups are only allowed with the Product screenshot preset. Both switches, the resolved logo
path, and the text setting are recorded in post metadata (image_include_logo, image_logo_reference_path,
image_allow_text).
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).
Reference / input images opens a gallery popover with multiple selection from workspace Social Media
images and reference-capable Styleguide assets. Up to three images can be selected for compatibility across
providers. Selected images appear as attachment thumbnails with individual remove buttons. Generation sends the
brand logo (when enabled) first, followed by the ordered workspace paths of the selected images, as
source_image_paths to the image API, which imports each source and forwards them to the provider. Selections
are recorded in post metadata as image_reference_paths; removing a selection does not delete the library image.
This flow is available in the React composer and bundled module. Native iOS/macOS Social Media editors
currently expose uploads and text drafting, but have no image-generation dialog. Agent-made post images get the
same treatment: when the chat or live agent calls images_generate while the Social Media module is active, the
prompt passes through POST generate-image-prompt (styleguide, logo reference, post-specific concept, no text)
unless the call supplies its own source images or brand_styleguide=false.
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 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.
| Platform | Connect via | Text limit | Media per post |
|---|---|---|---|
| OAuth popup from the module (admin-configured LinkedIn OAuth app) | 3000 | up to 1 image, no video (v1) | |
| X | native X app connection in Settings -> App-Verbindungen | 280 | up to 4 images or 1 video |
| Mastodon | instance URL + personal access token | 500 | up to 4 images or 1 video |
| Bluesky | handle + app password (optional service URL, default https://bsky.social) | 300 | up to 4 images (max 1 MB each), no video (v1) |
| YouTube | Google 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 + Instagram | 5000 | up 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 flow | 2200 | 1-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), withLINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRETenv fallbacks. - Default scopes:
openid profile email w_member_social(override viaLINKEDIN_OAUTH_SCOPES). - Accounts are stored in the existing
linkedin_accountstable; accounts connected through the legacylinkedinmodule keep working without reconnecting. Personal and company account types are supported throughoauth/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_secretwith the scopeshttps://www.googleapis.com/auth/youtube.uploadandhttps://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/completemust be whitelisted in the Google Cloud console OAuth client; override withSOCIAL_MEDIA_GOOGLE_REDIRECT_URIwhen needed. - The account row is stored in
social_media_accountswithplatform = 'youtube'; the handle shows the channel title. - Besides the module's Konten tab, the channel can also be connected/disconnected in
Settings -> App-Verbindungenvia the YouTube connection card (src/components/settings/youtube-connection-card.tsx), which usesGET accounts,GET oauth/start?platform=youtube, andDELETE 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, defaultpublic) controls visibility. A YouTube target requires exactly one attached video (youtube_requires_videoper-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), withMETA_OAUTH_CLIENT_ID/META_OAUTH_CLIENT_SECRETenv fallbacks. - One connect flow for Pages + page-linked Instagram:
GET oauth/start?platform=facebook(metais accepted as an alias) opens the Meta consent dialog with the scopespages_show_list,pages_read_engagement,pages_manage_posts,instagram_basic,instagram_content_publish, andbusiness_management. On completion the module exchanges the code for a long-lived user token, loadsGET /me/accounts, and creates onefacebookaccount row per Page plus oneinstagramaccount row per linked Instagram Business account (rows insocial_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/completemust be whitelisted under Facebook Login -> Settings -> Valid OAuth Redirect URIs in the Meta developer console; override withSOCIAL_MEDIA_META_REDIRECT_URIwhen needed. - Facebook Login for Business (new business apps): Meta business apps created since 2023 cannot request
the publishing permissions via the classic
scopeparameter — 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 intoApp Verbindungen -> Externe Integrationen -> Meta Login Config ID(app_settings.meta_login_config_id, env fallbackMETA_LOGIN_CONFIG_ID). When set,oauth/startsendsconfig_idinstead ofscope; 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 fromapp_settings.public_base_url(orPUBLIC_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 fallbacksINSTAGRAM_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 onhttps://www.instagram.com/oauth/authorizewith the scopesinstagram_business_basicandinstagram_business_content_publish. The redirect URI<publicBaseUrl>/api/modules/social-media/api/oauth/completemust be whitelisted in the Meta console under Instagram API -> API setup with Instagram login -> Business login settings (override withSOCIAL_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 viagraph.instagram.com/v21.0/me, and stores the account insocial_media_accountswithsession_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 statusexpiredand must be reconnected. - Coexistence with the page-based flow: both connect paths coexist. Direct-login accounts publish via
graph.instagram.comwith the IG user token; page-linked accounts publish viagraph.facebook.comwith 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.
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/completeURI 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 theSOCIAL_MEDIA_LINKEDIN_REDIRECT_URIenv 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/generateandPOST /api/generated-images/editclient-side (authenticated browser session), then re-uploads the resulting blob viaPOST media/importwithkind: "upload"andsource: "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 usesPOST posts/{id}/mediato 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/importwith{ kind: "video-studio", slug }copies<workspace>/video-studio/videos/<slug>.mp4directly (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), pollsGET /api/social-media/video-generation/{id}untilready, then imports with{ kind: "livestream-asset", assetId }; the handler resolveslivestream_assets.media_pathand copies the file. Available provider rows includexai-grok-videowhen 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}/publishpublishes NOW to all pending targets (or a provided subset). Each target recordsposted/failedplusremoteId/remoteUrl; the post ends asposted,partial, orfailed.POST posts/{id}/schedule({ scheduledFor }, future ISO timestamp, requires at least one target) andPOST posts/{id}/unscheduletoggle scheduled state. Scheduling also copies the timestamp into the editorialplannedFor; unscheduling keepsplannedFor, 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 byentrypoint.sh. Env:CLAPILOT_SOCIAL_MEDIA_PUBLISHER_ENABLED(defaulttrue),SOCIAL_MEDIA_PUBLISHER_POLL_SECONDS(default 30, minimum 5),SOCIAL_MEDIA_PUBLISHER_BATCH(default 5, 1-50),--once/SOCIAL_MEDIA_PUBLISHER_ONCE=truefor single-shot runs. - an opportunistic in-handler check: any handled module API request triggers a fire-and-forget
publish-duerun at most once per 60 seconds per process, so scheduled posts still go out in deployments without the worker.
- the post-publisher worker (
POST posts/publish-dueis 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_accountssocial_media_get_weekly_promptsocial_media_update_weekly_promptsocial_media_list_postssocial_media_regenerate_draftssocial_media_get_postsocial_media_create_draftsocial_media_update_postsocial_media_attach_mediasocial_media_schedule_postsocial_media_publish_postsocial_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_idmay be omitted when exactly one connected account exists for a platform; the tool proxy resolves it fromGET accounts. social_media_list_postsreturns posts in campaign order (see "Editorial order").social_media_create_draftandsocial_media_update_postacceptplanned_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 examplerelease: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_mediasources:generated_image(id fromimages_generate/images_edit),video_studio(video slug),livestream_asset(asset id fromlivestream_generate_video).social_media_regenerate_draftsis mutation-capable and runs only after a clear user request. It pages through everydraftandready_for_reviewpost 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_postremains available for standalone X replies/quotes outside the Social Media workflow; multi-platform posting should go through thesocial_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
Occupied-day markers in the date pickers
Both composer date fields ("Redaktioneller Termin" and the publish schedule under "Planung") open a Clapilot calendar popover instead of the browser's native picker, so the planner can see which days already carry a post before picking a slot. Each day shows small dots per kind of post already on it:
- navy: a post is scheduled for publication that day (
scheduledFor); - light blue: a post is only editorially planned that day (
plannedFor, no schedule yet); - grey: a post was published that day (
publishedAt, falling back toscheduledForfor failed runs).
Hovering or selecting a day lists the posts already on it (time and title, first four plus a "+N more" line);
the time is set in the same popover and the clear button removes the date. The post being edited is never
counted. The markers are derived client-side from the loaded post list (GET posts?limit=200) via
buildSocialPostDayIndex in src/components/modules/social-media/shared.ts; no API change is involved.
The Apple clients keep the native DatePicker and show the same information as a compact
"Already on this day: N" note with the first three post titles below the editorial date picker and in the
schedule sheet. Agents already see the same dates through social_media_list_posts.
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"), viaPOST/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:
- 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); - unpublished posts without any date, oldest created first;
posted,partial, andfailedposts, 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, encryptedsession_data). LinkedIn stays inlinkedin_accounts, X stays inx_user_integrations.social_media_posts:title(internal working title),content,status(draft|scheduled|publishing|posted|partial|failed),scheduled_for,planned_for(editorial planning date, migration005),published_at,targets(JSONB array),media(JSONB array),metadata. Rows are workspace-global;user_idrecords the creator for attribution and never scopes queries. The idempotency key ofPOST postsstays 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 inmetadata.weeklyPrompt. Exactly one row exists per instance, keyed by the constantscope = 'workspace';user_idrecords who last saved it. Migration004_workspace_visibility.sqlcollapses 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
| Method | Endpoint | Purpose |
|---|---|---|
| GET | health | module health |
| GET | bootstrap | legacy contract for the Settings LinkedIn connection card (linkedin account rows + oauth config flag) |
| GET | accounts | all connected accounts across platforms plus platforms[] info (`kind: oauth |
| POST | accounts/mastodon | connect a Mastodon account ({ instanceUrl, accessToken, label? }) |
| POST | accounts/bluesky | connect a Bluesky account ({ handle, appPassword, serviceUrl?, label? }) |
| DELETE | accounts/{platform}/{id} | disconnect an account (mastodon/bluesky/youtube rows, linkedin via linkedin_accounts; x is rejected with 400 — managed in Settings) |
| DELETE | accounts/{id} | legacy alias: treated as a LinkedIn account id (Settings card contract) |
| GET | posts?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) |
| POST | posts | create draft ({ title?, content, targets?, media?, scheduledFor?, plannedFor? }; scheduledFor requires targets, plannedFor does not) |
| GET | posts/{id} | read one post (includes plannedFor) |
| PATCH | posts/{id} | update draft fields incl. plannedFor (null/empty clears); clearing scheduledFor reverts to draft and keeps the editorial date |
| DELETE | posts/{id} | delete post (blocked with 409 while publishing) |
| POST | posts/{id}/publish | publish now ({ targets? } optional subset) |
| POST | posts/{id}/schedule | schedule ({ scheduledFor }) |
| POST | posts/{id}/unschedule | back to draft |
| POST | posts/publish-due | claim + publish due scheduled posts across users ({ limit? }, worker/service entry) |
| POST | generate | AI draft ({ brief, tone?, platforms?, language? }) |
| GET | media | list media workspace items |
| POST | media/import | import media (`kind: upload |
| GET | media/file?path= | serve one media binary inline |
| GET | media/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) |
| DELETE | media?path= | delete a media file (409 while referenced by an unpublished post) |
| GET/POST | `oauth/start?platform=linkedin | youtube |
| GET | oauth/complete | public 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-generationwith{ 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 returns202 { 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 viamedia/importkind: "livestream-asset".
Troubleshooting
- LinkedIn/YouTube connect fails at the provider's authorize step — the new redirect URI
<publicBaseUrl>/api/modules/social-media/api/oauth/completeis 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'sDELETE 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.
Shared generated media
The Media library action browses workspace-global generated output, with folder selection, nested folder creation, search, previews, and moving assets between folders. Existing files are referenced in place. Image Playground results can be reused in Social Media; Video Studio exports and completed scene clips can be imported into Social Media or Livestream. Livestream imports copy into the existing streamer media mount and do not enqueue or start playback. Social Media imports attach to the current draft and do not publish it.
The same library is available in web, iOS, and macOS. See Image Playground for visibility rules and agent access. Deployment requires 311_media_library.sql. Source removal can make a library reference unavailable; moving a library item never moves the source file.
Separate short text for X
The editor has a separate, editable Short text for X field on web, iOS, and macOS. AI drafting and review rewrites generate both the full post and a concise X version. Generate short text for X also adapts an existing post without replacing its main text. Review both versions before publishing; editing the main text preserves an existing X version, so regenerate or edit the short text when the message changes.
Only X receives the short version, for both immediate and scheduled publication. Other targets continue using the full text and the same attachments. Hashtag suggestions for the full post are not appended to the X version. Existing posts without a short version get a local, shortened fallback; no provider call or migration is required. Clearing the field resets that fallback on save.
The 280-character budget uses NFC Unicode weights and reserves at least 23 characters for URL-like tokens. It deliberately counts complex emoji and long URLs conservatively rather than claiming an exact X character count. Automatic shortening preserves graphemes and whole links; explicit over-budget edits are rejected before saving or sending. The existing approval and publication actions still apply.
