When Forge publishes a content item, archives one, or deletes one, it fires a signal. That signal is a hook into the request lifecycle — a place where you can attach behaviour without touching the content type itself.
app.OnSignal() is the primitive. It's part of forge core, not forge-social. You can use it directly for anything that runs in-process: writing to an audit log, invalidating a cache, pushing an SSE event to connected clients. See Signal bus.
forge-social's AddRoutes is one thing you can build on top of that primitive: a declarative, database-backed outbound HTTP layer that delivers signed payloads to agent URLs. This post is about AddRoutes — but the point is that the signal bus is the primitive. AddRoutes is just one use of it.
What the signal bus gives you
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
// ev.Type — "Post", "Recipe", whatever content type was published
// ev.Slug — the content slug
// ev.Title — the content title
// ev.URL — canonical URL
log.Printf("published: %s %s", ev.Type, ev.Slug)
return nil
})
This runs synchronously in the request context. It's the right tool for:
- Audit logs
- Cache invalidation
- SSE notifications to the CMS UI
- In-process metrics
- Anything that should happen immediately, in the same process, with no retry
If the handler returns an error, the request lifecycle sees it.
AddRoutes: the outbound layer
For cross-process work — sending a payload to a remote agent — you want queuing, retries, and delivery guarantees. That's what AddRoutes adds on top of the signal bus.
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"),
)
When Forge fires AfterPublish for a Post, forge-social enqueues an HTTP POST job to https://agent.example.com/hooks/post-published. A background worker delivers it, retries on failure, and logs every attempt.
Under the hood, AddRoutes calls app.OnSignal() for each signal it needs — it's using the same primitive you'd use directly. The difference is the queue, the signing, and the retry logic that forge-social provides.
The two-layer model
forge-social ships two layers, and understanding why Layer 2 shipped first matters.
Layer 2 — Scheduler: Forge signal → ScheduledPost record → platform API (Mastodon, LinkedIn). This is the problem that motivated the module. An operator wants a published post to appear on Mastodon at 09:00. The scheduler solves it.
Layer 1 — AddRoutes: Forge signal → agent URL → agent does whatever it wants. This is the extension point. An AI agent that monitors content can now react to it in real time.
Layer 2 shipped in v0.1.0 because it solved a concrete problem. Layer 1 shipped in v0.3.0 because it closes the loop: the agent that AddRoutes notifies can turn around and call create_scheduled_post via MCP, drafting a social post from the published content.
What the agent receives
Every AddRoutes delivery is a signed HTTP POST with a JSON body:
{
"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"
}
The request includes X-Forge-Signature: sha256=<HMAC-SHA256>. Compute it yourself and compare — reject anything that doesn't match:
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))
}
The key is Config.Secret — the same secret that encrypts OAuth tokens. One root secret, consistent across the forge-social stack.
The full example: publish → draft → schedule
Here's the complete loop that AddRoutes enables when combined with the scheduler:
1. Operator publishes a Post via MCP. 2. Forge fires AfterPublish("Post"). 3. AddRoutes enqueues an HTTP POST to https://agent.example.com/hooks/post-published. 4. The delivery worker sends it. Agent receives the forge.SignalEvent. 5. Agent reads ev.Title and ev.URL, drafts a social post body. 6. Agent calls create_scheduled_post via MCP: create_scheduled_post credential_id: "mastodon-main" platform: "mastodon" body: "New post: {title} — {url}" scheduled_at: "2026-05-13T09:00:00Z" 7. Operator reviews the draft in the MCP list_scheduled_posts tool. 8. Operator approves (or edits body, adjusts time). 9. At 09:00, the Layer 2 scheduler publishes it to Mastodon.
The content never left the Forge stack. No webhooks registered on an external platform. No Zapier. The agent read a signal, drafted a post, and handed it back to the operator for review.
SSRF protection
Agent URLs are validated at AddRoutes time — before any request is served. A private IP, localhost, or .local domain causes a startup panic:
- Private IP ranges:
10.x,172.16–31.x,192.168.x,127.x,::1 localhostand.localdomains- Non-HTTPS URLs
Misconfiguration is caught immediately, not at delivery time.
Delivery guarantees
Jobs are persisted in the database. A server restart does not lose the queue.
| Response | Behaviour |
|---|---|
| 2xx | Delivered — job closed |
| 4xx (non-429) | Terminal — no retry. A 400/404/422 means the agent rejected the payload. Fix the agent, not the message. |
| 429 | Respects Retry-After header |
| 5xx / network error | Retried with exponential backoff |
Retry schedule: 30 s → 2 min → 10 min → 1 h → terminal.
Social.Stop() waits for in-flight deliveries before returning. Always defer it in your shutdown handler.
What else you can build with the signal bus
AddRoutes is one pattern. The signal bus supports anything:
- Audit log:
app.OnSignal(forge.AfterDelete, auditLogger.Record)— sync, no queue needed - Cache invalidation: invalidate a CDN edge key the moment a post is published
- SSE push: notify connected CMS clients when content changes
- Your own outbound worker: call
app.OnSignal()directly and enqueue to whatever queue fits your stack
See Signal bus for the full primitives and how to compose them.