forge-social: scheduling social posts from your Forge CMS

forge-social adds social post scheduling directly to your Forge application — no third-party queue, no Zapier. Here's how it works.

Social publishing is almost always bolted on. Content gets written in the CMS, then someone manually copies it to Buffer, Hootsuite, or a Zapier zap. The CMS knows when a post is published. The queue doesn't. They're separate systems with separate auth, separate logs, and separate failure modes.

forge-social collapses this. When you add it to a Forge application, scheduled social posts become content records — same database, same auth, same operator tools, same audit trail. The scheduler runs inside your app. Nothing else to deploy.


ScheduledPost: a content type, not a config knob

The first thing forge-social gives you is a content type called ScheduledPost. It has fields:

  • platformmastodon or linkedin
  • body — the post text (Mastodon: 500 chars; LinkedIn: 3000 chars)
  • media_url — optional image attachment
  • scheduled_at — when to publish (RFC3339)
  • statusdraft, scheduled, published, failed, archived
  • credential_id — which OAuth credential to use

The reason it's a content type rather than a queue entry is that it has history. You can see what was posted, when, whether it failed, and why. Operators can review drafts before they go out. AI agents can create posts from published content, and a human can approve before scheduling.

The lifecycle:

draft → scheduled → published
              ↓
           failed (up to 5 attempts, then terminal)

PlatformCredential: encrypted OAuth tokens in your DB

PlatformCredential stores the OAuth access tokens that allow posting to Mastodon and LinkedIn. Tokens are encrypted at rest using AES-256-GCM, keyed from Config.Secret — the same secret your Forge app uses for session signing. They are never stored in plaintext.

This matters: most social integrations store tokens in environment variables or pass them through a third-party vault. With forge-social, the tokens live in your database, encrypted with a key you control, with the same backup and recovery story as the rest of your content.

The HMAC on outbound requests (covered in the agent routing devlog) uses the same secret, so there's one root secret for the entire forge-social stack.


The scheduler

The scheduler runs as a goroutine inside your app. It wakes up on an adaptive tick: fast when posts are due soon, slow when the queue is empty. No cron, no external worker.

When a ScheduledPost reaches its scheduled_at time, the scheduler calls the platform API. If it fails — network error, API rate limit, token expiry — it retries with exponential backoff:

AttemptDelay before retry
130 seconds
22 minutes
310 minutes
41 hour
5Terminal — status set to failed

After 5 failed attempts the post is marked failed and the operator is notified (via the MCP list_scheduled_posts tool, which surfaces failed posts). Fix the underlying problem — reconnect the credential, increase the body length — then call publish_scheduled_post to retry manually.


Mastodon OAuth flow

Connecting a Mastodon account takes four steps:

1. Create a SocialCredential record via create_social_credential. 2. Direct the operator (or AI agent) to GET /oauth/mastodon/start?credential_id={id}. 3. The operator authorises on the Mastodon instance. 4. The callback at /oauth/mastodon/callback stores the encrypted access token.

The credential is now live. Any ScheduledPost with platform: mastodon and that credential_id will publish through it.

Mastodon tokens don't expire. You connect once.


LinkedIn (v0.2.0)

LinkedIn support shipped in v0.2.0 with the same OAuth pattern. The difference is that LinkedIn tokens expire after 60 days and LinkedIn requires a "person URN" (your LinkedIn member ID) on every post request.

forge-social fetches the person URN at OAuth time and stores it as ActorID on the PlatformCredential. You don't have to think about it again — until the token expires.

When a LinkedIn post fails with an auth error, the fix is to repeat the OAuth flow with the same credential_id. The token is replaced, the ActorID is refreshed, and the queue resumes.

The platform field on ScheduledPost defaults to mastodon when omitted, so v0.1.0 apps that didn't set it continue to work unchanged.


Operator and AI agent experience

An operator — human or AI — can manage the entire social publishing workflow through MCP tools:

create_social_credential  → connect_mastodon_oauth / connect_linkedin_oauth
create_scheduled_post     → (set scheduled_at or call publish immediately)
list_scheduled_posts      → review queue, surface failures
publish_scheduled_post    → immediate publish or manual retry
archive_scheduled_post    → pull a post before it goes out

These are also accessible as REST endpoints auto-generated by forge-social on registration:

POST /social/posts      — create a scheduled post
GET  /social/posts      — list posts
GET  /social/posts/{slug}
PUT  /social/posts/{slug}
DELETE /social/posts/{slug}

You don't need to use the MCP layer — REST access works directly for any client with a valid Bearer token.


Installation in three steps

go get forge-cms.dev/forge-social@latest
social := forgesocial.New(db, forgesocial.Config{
    Secret: cfg.Secret,
    Mastodon: forgesocial.MastodonConfig{
        ClientID:     os.Getenv("MASTODON_CLIENT_ID"),
        ClientSecret: os.Getenv("MASTODON_CLIENT_SECRET"),
        InstanceURL:  os.Getenv("MASTODON_INSTANCE_URL"),
        RedirectURL:  cfg.BaseURL + "/oauth/mastodon/callback",
    },
})
social.Register(app)
defer social.Stop()

Add forgemcp.WithModule(social.PostModule()) and forgemcp.WithModule(social.CredentialModule()) to your MCP server if you want AI agent access. That's it.

Full reference: forge-social docs.