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
ScheduledPostrecords; 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
| Field | Type | Description |
|---|---|---|
Secret | []byte | Required. Must match forge.Config.Secret. Used to derive the AES-256-GCM key for encrypting stored OAuth tokens. |
Mastodon.ClientID | string | OAuth 2.0 client ID from your Mastodon instance. |
Mastodon.ClientSecret | string | OAuth 2.0 client secret. |
Mastodon.InstanceURL | string | Base URL of the Mastodon instance (e.g. https://mastodon.social). |
Mastodon.RedirectURL | string | Must be {BaseURL}/oauth/mastodon/callback. |
LinkedIn.ClientID | string | OAuth 2.0 client ID from the LinkedIn developer portal. |
LinkedIn.ClientSecret | string | OAuth 2.0 client secret. |
LinkedIn.RedirectURL | string | Must 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:
| Tool | Description |
|---|---|
create_publication_schedule | Create a recurring schedule for a credential |
get_publication_schedule | Read schedule by ID |
list_publication_schedules | List all schedules |
update_publication_schedule | Add/remove slots, pause/resume |
delete_publication_schedule | Delete 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
| Tool | Description |
|---|---|
create_scheduled_post | Creates a draft post. Set scheduled_at to schedule it. |
list_scheduled_posts | Lists posts, optionally filtered by status. |
publish_scheduled_post | Publishes a draft immediately, bypassing the scheduler. |
archive_scheduled_post | Moves a post to archived. Stops any pending delivery. |
delete_scheduled_post | Permanently deletes a post. |
SocialCredential
| Tool | Description |
|---|---|
create_social_credential | Creates a new credential record (empty — connect via OAuth). |
list_social_credentials | Lists all credentials with platform and connection status. |
get_social_credential | Reads a single credential by ID. |
delete_social_credential | Permanently deletes a credential and any linked posts. |
REST API and CLI
social.Register(app) wires auto-generated REST endpoints on forge.App:
| Method | Path | Description |
|---|---|---|
POST | /social/posts | Create a scheduled post |
GET | /social/posts | List 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:
| Function | Signal |
|---|---|
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
| Response | Behaviour |
|---|---|
| 2xx | Delivered — no retry |
| 4xx (non-429) | Terminal — no retry (fix the agent, not the payload) |
| 429 | Respects Retry-After header |
| 5xx / network error | Transient — 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.