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, LinkedIn, or X (Twitter) 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 smeldr.dev/social@latest

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


Wiring

import (
    forgesocial "smeldr.dev/social"
    forgemcp    "smeldr.dev/mcp"
)

social := forgesocial.New(db, forgesocial.Config{
    Secret: cfg.Secret,
    // Platform credentials are configured via create_platform_config MCP tool (Admin).
    // No environment variables required.
})

// 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.ConfigModule()),   // create_platform_config (Admin)
    forgemcp.WithModule(social.ScheduleModule()),
)

Backwards compatibility: MastodonConfig and LinkedInConfig fields on forgesocial.Config still work as a deprecated fallback. If the DB has no config for a platform but the struct has credentials, those are used — with a deprecation warning logged at startup. Migrate by calling create_platform_config once, then removing the env vars.


Platform configuration (v0.5.0+)

Platform OAuth credentials are stored encrypted in the database. No environment variables are required after initial setup. Use the create_platform_config MCP tool (Admin role) once per platform:

Mastodon:

create_platform_config
  platform:      "mastodon"
  client_id:     "..."
  client_secret: "..."
  instance_url:  "https://mastodon.social"
  redirect_url:  "https://yoursite.com/oauth/mastodon/callback"

LinkedIn:

create_platform_config
  platform:      "linkedin"
  client_id:     "..."
  client_secret: "..."
  redirect_url:  "https://yoursite.com/oauth/linkedin/callback"

X (Twitter):

create_platform_config
  platform:      "x"
  client_id:     "..."
  client_secret: "..."
  redirect_url:  "https://yoursite.com/oauth/x/callback"

Register your app at developer.twitter.com with OAuth 2.0 + PKCE enabled. The callback URL must be whitelisted in your X app settings. instance_url is not accepted for platform "x" — the API base is fixed.

Credentials are encrypted with AES-256-GCM keyed from Config.Secret. The confirmation response never echoes stored credentials. Platform config takes effect immediately — no restart required.


Configuration

FieldTypeDescription
Secret[]byteRequired. Must match forge.Config.Secret. Used to derive the AES-256-GCM key for encrypting stored tokens and platform credentials.
MastodonMastodonConfigDeprecated. Env-var fallback. Use create_platform_config instead.
LinkedInLinkedInConfigDeprecated. Env-var fallback. Use create_platform_config instead.

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" or "x"
  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; X 280 characters (validation error if exceeded — never truncated).

Optional media: Set media_url to an accessible HTTPS image URL to attach it to the post. Not supported for X in v0.5.0.


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.


X (Twitter) OAuth connect flow (v0.5.0+)

X uses OAuth 2.0 with PKCE (S256). The code verifier is generated server-side and never exposed.

1. Configure X credentials via create_platform_config (see above). 2. Call create_social_credential with platform: "x". 3. Open the returned redirect_url in a browser — this is the X authorisation page. 4. X redirects to /oauth/x/callback after authorisation. 5. The callback exchanges the code (with PKCE verifier) for tokens and stores them encrypted. 6. The credential is ready to use with platform: "x".

Body limit: 280 characters. Exceeding the limit returns a validation error — forge-social never truncates silently.

Token refresh: X issues a refresh token alongside the access token. forge-social stores both; renewal is handled automatically on expiry.

Media: Not supported in v0.5.0. Text-only posts only.


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_credentialInitiates OAuth flow — returns redirect URL to open in browser.
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.

PlatformConfig (Admin)

ToolDescription
create_platform_configStore encrypted OAuth app credentials for a platform. Replaces any existing config. Never echoes secrets.

Wire: forgemcp.WithModule(social.ConfigModule())


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.8.0 includes a social command group:

Posts:

forge-cli social post create --platform mastodon|linkedin|x --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 get <id>
forge-cli social post queue --credential <id> --body "..." [--platform mastodon|linkedin|x]
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|x [--instance-url <url>]
forge-cli social credential list
forge-cli social credential get <id>
forge-cli social credential delete <id>

credential create prints the OAuth start URL to open in your browser. --instance-url is only valid for --platform mastodon.

Platform configuration:

forge-cli social platform configure \
  --platform mastodon|linkedin|x \
  --client-id <id> \
  --client-secret <secret> \
  --redirect-url <url> \
  [--instance-url <url>]   (mastodon only) \
  [--success-url <url>]

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.