Documentation
Modules

forge-social

forge-social is a standalone Go module that adds social post scheduling and AI agent routing to any Forge application. It ships two layers:

  • Layer 2 — Scheduler: Create ScheduledPost records; the built-in scheduler publishes them to Mastodon or LinkedIn at the right time.
  • Layer 1 — Agent routing: Wire Forge lifecycle signals to outbound HTTP calls, so AI agents can react when content is published, scheduled, archived, or deleted.

Neither layer requires changes to forge core. forge-social sits alongside your application and registers its own DB tables, HTTP routes, and MCP tools.


Installation

go get forge-cms.dev/forge-social@latest

Requires Go 1.26+ and a forge.DB (SQLite or Postgres).


Wiring

import (
    forgesocial "forge-cms.dev/forge-social"
    forgemcp    "forge-cms.dev/forge-mcp"
)

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",
    },
    LinkedIn: forgesocial.LinkedInConfig{
        ClientID:     os.Getenv("LINKEDIN_CLIENT_ID"),
        ClientSecret: os.Getenv("LINKEDIN_CLIENT_SECRET"),
        RedirectURL:  cfg.BaseURL + "/oauth/linkedin/callback",
    },
})

// Register HTTP routes (OAuth callbacks, REST endpoints).
social.Register(app)

// Graceful shutdown — always defer Stop().
defer social.Stop()

// Wire MCP tools so AI agents can create and publish posts.
mcpSrv := forgemcp.New(app,
    forgemcp.WithModule(social.PostModule()),
    forgemcp.WithModule(social.CredentialModule()),
    forgemcp.WithModule(social.ScheduleModule()),
)

LinkedIn is optional. Omit LinkedInConfig entirely to disable it.


Configuration

FieldTypeDescription
Secret[]byteRequired. Must match forge.Config.Secret. Used to derive the AES-256-GCM key for encrypting stored OAuth tokens.
Mastodon.ClientIDstringOAuth 2.0 client ID from your Mastodon instance.
Mastodon.ClientSecretstringOAuth 2.0 client secret.
Mastodon.InstanceURLstringBase URL of the Mastodon instance (e.g. https://mastodon.social).
Mastodon.RedirectURLstringMust be {BaseURL}/oauth/mastodon/callback.
LinkedIn.ClientIDstringOAuth 2.0 client ID from the LinkedIn developer portal.
LinkedIn.ClientSecretstringOAuth 2.0 client secret.
LinkedIn.RedirectURLstringMust be {BaseURL}/oauth/linkedin/callback.

ScheduledPost workflow

A ScheduledPost is a first-class content record. It follows this lifecycle:

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

Creating a post (via MCP):

create_scheduled_post
  credential_id: "abc123"
  platform:      "mastodon"        # or "linkedin"
  body:          "New post is live: {title} — {url}"
  scheduled_at:  "2026-05-15T09:00:00Z"

The scheduler picks it up automatically. No polling required. To publish immediately, call publish_scheduled_post instead of setting scheduled_at.

Body limits: Mastodon 500 characters; LinkedIn 3000 characters.

Optional media: Set media_url to an accessible HTTPS image URL to attach it to the post.


Slot queue (v0.4.0)

In addition to explicit scheduling, forge-social supports a slot-queue model. Define recurring publication times on a credential — posts without a scheduled_at are queued and published automatically at the next available slot.

Create a publication schedule:

create_publication_schedule
  credential_id: "abc123"
  slots: [{"weekday": 1, "time": "09:00", "timezone": "Europe/Copenhagen"},
          {"weekday": 3, "time": "09:00", "timezone": "Europe/Copenhagen"},
          {"weekday": 5, "time": "09:00", "timezone": "Europe/Copenhagen"}]

Weekday: 0 = Sunday, 1 = Monday … 6 = Saturday.

Queue a post (no scheduled_at):

create_scheduled_post
  credential_id: "abc123"
  platform:      "mastodon"
  body:          "New post is live: {title} — {url}"
  status:        "queued"

The scheduler publishes it at the next fired slot for that credential. FIFO order. If the server was down when a slot fired, it catches up — one post per missed slot.

MCP tools for schedules:

ToolDescription
create_publication_scheduleCreate a recurring schedule for a credential
get_publication_scheduleRead schedule by ID
list_publication_schedulesList all schedules
update_publication_scheduleAdd/remove slots, pause/resume
delete_publication_scheduleDelete schedule

Wire the module: forgemcp.WithModule(social.ScheduleModule())


Mastodon OAuth connect flow

1. Call create_social_credential to create a SocialCredential record. 2. Direct the operator 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. 5. The credential is ready to use in ScheduledPost.CredentialID.

Tokens are encrypted with AES-256-GCM, keyed from Config.Secret. They are never stored in plaintext.


LinkedIn OAuth connect flow

1. Call create_social_credential to create a SocialCredential record. 2. Direct the operator to GET /oauth/linkedin/start?credential_id={id}. 3. The operator authorises in LinkedIn. 4. The callback at /oauth/linkedin/callback fetches the person URN and stores it alongside the encrypted token. 5. The credential is ready to use with platform: "linkedin".

Token expiry: LinkedIn tokens expire after 60 days. When a post fails with an auth error, reconnect by repeating the OAuth flow with the same credential_id.


MCP tools reference

ScheduledPost

ToolDescription
create_scheduled_postCreates a draft post. Set scheduled_at to schedule it.
list_scheduled_postsLists posts, optionally filtered by status.
publish_scheduled_postPublishes a draft immediately, bypassing the scheduler.
archive_scheduled_postMoves a post to archived. Stops any pending delivery.
delete_scheduled_postPermanently deletes a post.

SocialCredential

ToolDescription
create_social_credentialCreates a new credential record (empty — connect via OAuth).
list_social_credentialsLists all credentials with platform and connection status.
get_social_credentialReads a single credential by ID.
delete_social_credentialPermanently deletes a credential and any linked posts.

REST API and CLI

social.Register(app) wires auto-generated REST endpoints on forge.App:

MethodPathDescription
POST/social/postsCreate a scheduled post
GET/social/postsList posts
GET/social/posts/{slug}Read a single post
PUT/social/posts/{slug}Update a post
DELETE/social/posts/{slug}Delete a post

These endpoints follow the same auth rules as all Forge content — Bearer token required.

CLI

forge-cli v0.7.0 includes a social command group:

Posts:

forge-cli social post create --platform mastodon --credential <id> --body "..." [--at "2026-05-15T09:00:00Z"]
forge-cli social post list [--status draft|queued|scheduled|published|failed|archived]
forge-cli social post queue --credential <id> --body "..." [--platform mastodon|linkedin]
forge-cli social post publish <slug>
forge-cli social post archive <slug>
forge-cli social post delete <slug>

Credentials:

forge-cli social credential create --platform mastodon|linkedin
forge-cli social credential list

credential create prints the OAuth start URL to open in your browser.

Schedules:

forge-cli social schedule create --credential <id> --slot "monday 09:00 Europe/Copenhagen" [--slot ...]
forge-cli social schedule show --credential <id>
forge-cli social schedule pause --credential <id>
forge-cli social schedule resume --credential <id>
forge-cli social schedule delete --credential <id>

Agent routing (Layer 1)

forge-social's AddRoutes is one way to act on Forge signals. For in-process handlers — audit logs, cache invalidation, SSE push — use app.OnSignal() directly. See Signal bus.

AddRoutes is the outbound layer: it enqueues signed HTTP POSTs to agent URLs whenever a matching Forge signal fires. The agent can then call MCP tools, update external systems, or trigger any downstream workflow.

social.AddRoutes(app,
    forgesocial.OnPublish("Post",   "https://agent.example.com/hooks/post-published"),
    forgesocial.OnArchive("Post",   "https://agent.example.com/hooks/post-archived"),
    forgesocial.OnPublish("Recipe", "https://agent.example.com/hooks/recipe-published"),
)

Builder functions:

FunctionSignal
OnPublish(contentType, agentURL)forge.AfterPublish
OnSchedule(contentType, agentURL)forge.AfterSchedule
OnArchive(contentType, agentURL)forge.AfterArchive
OnDelete(contentType, agentURL)forge.AfterDelete

contentType must be the exact PascalCase struct name of the content type. A misconfigured URL or private IP address causes a panic at startup — misconfiguration is caught before any request is served.

SSRF rules: Agent URLs must be HTTPS. Private IP ranges (10.x, 172.16–31.x, 192.168.x, 127.x, ::1), localhost, and .local domains are rejected.

Payload

Each outbound POST carries a JSON-encoded forge.SignalEvent:

{
  "type":           "Post",
  "slug":           "my-first-post",
  "title":          "My First Post",
  "url":            "https://mysite.com/posts/my-first-post",
  "timestamp":      "2026-05-12T14:30:00Z",
  "previous_state": "",
  "actor_role":     "Author",
  "actor_id":       "user-abc"
}

Verifying the signature

Every request includes X-Forge-Signature: sha256=<HMAC-SHA256>. The HMAC is computed over the raw request body using Config.Secret as the key.

Verify it in your agent handler:

func verifyForgeSignature(body []byte, secret []byte, header string) bool {
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(header))
}

Reject any request where the signature does not match.

Delivery and retry

ResponseBehaviour
2xxDelivered — no retry
4xx (non-429)Terminal — no retry (fix the agent, not the payload)
429Respects Retry-After header
5xx / network errorTransient — retried with exponential backoff

Retry schedule: 30 s → 2 min → 10 min → 1 h → terminal.

Jobs are persisted in SQLite. A server restart does not lose queued deliveries.


Graceful shutdown

Social.Stop() waits for both the scheduler and the route delivery worker to finish any in-flight work before returning. Always call it in your shutdown handler:

defer social.Stop()

Or in a signal handler:

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
social.Stop()

Without Stop(), in-flight scheduler attempts and outbound deliveries may be abandoned mid-flight, leaving posts in an inconsistent state.