Social post scheduling and AI agent routing for Forge applications.
v0.6.0 — stable. See CHANGELOG.md.
go get forge-cms.dev/forge-social@latestforge-social adds two layers to any Forge application:
Layer 2 — Scheduler: Create ScheduledPost records; the built-in scheduler publishes them to Mastodon, LinkedIn, or X (Twitter) at the right time, with exponential backoff and automatic retries.
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.
import (
forgesocial "forge-cms.dev/forge-social"
forgemcp "forge-cms.dev/forge-mcp"
)
social := forgesocial.New(db, forgesocial.Config{
Secret: cfg.Secret,
})
social.Register(app) // wire OAuth callbacks + REST endpoints
defer social.Stop() // drain scheduler + delivery worker on shutdown
// Wire MCP tools (optional).
mcpSrv := forgemcp.New(app,
forgemcp.WithModule(social.PostModule()),
forgemcp.WithModule(social.CredentialModule()),
forgemcp.WithModule(social.ConfigModule()), // create_platform_config (Admin)
forgemcp.WithModule(social.ScheduleModule()), // slot-queue (v0.4.0+)
)
// Wire agent routing (optional — Layer 1).
social.AddRoutes(app,
forgesocial.OnPublish("Post", "https://agent.example.com/hooks/post-published"),
)Platform credentials are stored in the database — no environment variables required after initial setup. Call create_platform_config via MCP (Admin role) to configure each platform.
Backwards compat:
Config.MastodonandConfig.LinkedInare still accepted as fallbacks but are deprecated. A warning is logged at startup when env-var config is present and no DB config exists for that platform.
draft → scheduled → published
↓
failed (up to 5 attempts, then terminal)
↓
archived
Create a post via MCP tool create_scheduled_post, set scheduled_at, and the scheduler handles the rest. Call publish_scheduled_post to publish immediately without waiting.
Platforms: mastodon (default), linkedin, or x.
Body limits: Mastodon 500 characters; LinkedIn 3000 characters; X 280 characters (returns terminal error if exceeded — not truncated).
Media: Set media_url to attach an HTTPS image URL (Mastodon and LinkedIn only; X media is not yet supported).
Instead of a fixed scheduled_at time, posts can be queued for the next available slot in a PublicationSchedule.
// Via MCP: create_publication_schedule
// credential_id: the ID of a connected SocialCredential
// slots: JSON array of slot objects
// status: "active" (default) or "paused"Slot format:
{ "weekday": 1, "time": "09:00", "timezone": "Europe/Copenhagen" }weekday: 0 = Sunday … 6 = Saturday (matches Gotime.Weekday)time: HH:MM in 24-hour formattimezone: IANA timezone name (e.g."Europe/Copenhagen","America/New_York")
Each credential may have at most one schedule.
Set status: "queued" on create_scheduled_post (omit scheduled_at). The scheduler dequeues the oldest queued post for each credential whenever a slot fires.
draft → queued → published
If the server was offline when a slot fired, the scheduler catches up on the next tick. One post is published per missed slot. The total catch-up per tick is capped at len(slots) to avoid flooding.
active— slots fire normallypaused— slots are skipped; queued posts remain in the queue
- Call
create_platform_config(Admin) — setplatform=mastodon,client_id,client_secret,redirect_url,instance_url - Call
create_social_credential(MCP) → getredirect_url - Operator visits
redirect_urlin browser and authorises - Callback stores encrypted token automatically
- Tokens do not expire — connect once
- Call
create_platform_config(Admin) — setplatform=linkedin,client_id,client_secret,redirect_url - Call
create_social_credential(MCP) withplatform=linkedin→ getredirect_url - Operator visits
redirect_urlin browser and authorises - Callback stores token + person URN automatically
- Tokens expire after 60 days — repeat OAuth flow to reconnect
- Call
create_platform_config(Admin) — setplatform=x,client_id,client_secret,redirect_url - Call
create_social_credential(MCP) withplatform=x→ getredirect_url(contains PKCE challenge — single use) - Operator visits
redirect_urlin browser and authorises - Callback validates PKCE code verifier and stores token automatically
- The
code_verifieris stored server-side; the agent never sees it
OAuth tokens are encrypted at rest with AES-256-GCM, keyed from Config.Secret. Never stored in plaintext.
| Tool | Description |
|---|---|
create_platform_config |
Configure OAuth 2.0 app credentials for a platform (Admin) |
create_scheduled_post |
Create a draft post |
list_scheduled_posts |
List posts by status |
publish_scheduled_post |
Publish immediately or retry a failed post |
archive_scheduled_post |
Archive a post |
delete_scheduled_post |
Permanently delete a post |
create_social_credential |
Initiate OAuth connect flow; returns redirect_url |
list_social_credentials |
List all credentials |
get_social_credential |
Read a credential by ID |
delete_social_credential |
Delete a credential |
create_publication_schedule |
Create a recurring slot schedule for a credential |
get_publication_schedule |
Read a schedule by ID |
update_publication_schedule |
Update slots or pause/resume |
list_publication_schedules |
List all schedules |
delete_publication_schedule |
Delete a schedule |
social.Register(app) wires REST endpoints auto-generated by Forge:
POST /social/posts
GET /social/posts
GET /social/posts/{slug}
PUT /social/posts/{slug}
DELETE /social/posts/{slug}
Bearer token required on all endpoints.
AddRoutes is one way to act on Forge signals. For in-process handlers (audit logs, cache invalidation, SSE), use app.OnSignal() directly — see the Signal bus docs.
social.AddRoutes(app,
forgesocial.OnPublish("Post", "https://agent.example.com/hook"),
forgesocial.OnArchive("Post", "https://agent.example.com/hook"),
forgesocial.OnSchedule("Event", "https://agent.example.com/hook"),
forgesocial.OnDelete("Post", "https://agent.example.com/hook"),
)SSRF protection: Agent URLs must be HTTPS. Private IPs, localhost, and .local domains are rejected at startup (panic).
{
"type": "Post",
"slug": "my-post",
"title": "My Post",
"url": "https://mysite.com/posts/my-post",
"timestamp": "2026-05-12T14:30:00Z",
"previous_state": "",
"actor_role": "Author",
"actor_id": "user-abc"
}Every POST includes X-Forge-Signature: sha256=<HMAC-SHA256 of body, key=Config.Secret>.
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))
}| Attempt | Delay |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5+ | Terminal |
2xx = delivered. 4xx (non-429) = terminal (no retry). 429 = honour Retry-After. 5xx/network = transient retry.
Jobs survive restarts — persisted in SQLite.
Always call social.Stop(). It waits for the scheduler and delivery worker to finish in-flight work:
defer social.Stop()- Go 1.26+
forge-cms.dev/forgev1.20.0+- A
forge.DB(SQLite or Postgres)
AGPL-3.0. See LICENSE.