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, 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
| Field | Type | Description |
|---|---|---|
Secret | []byte | Required. Must match forge.Config.Secret. Used to derive the AES-256-GCM key for encrypting stored tokens and platform credentials. |
Mastodon | MastodonConfig | Deprecated. Env-var fallback. Use create_platform_config instead. |
LinkedIn | LinkedInConfig | Deprecated. 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:
| 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.
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
| 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 | Initiates OAuth flow — returns redirect URL to open in browser. |
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. |
PlatformConfig (Admin)
| Tool | Description |
|---|---|
create_platform_config | Store 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:
| 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.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:
| 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.