forge-social's AddRoutes: a concrete example of what the signal bus enables

Forge's signal bus is a general extension point. forge-social's AddRoutes is one concrete thing you can build on top of it — outbound HTTP delivery to AI agents.

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
  • localhost and .local domains
  • 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.

ResponseBehaviour
2xxDelivered — job closed
4xx (non-429)Terminal — no retry. A 400/404/422 means the agent rejected the payload. Fix the agent, not the message.
429Respects Retry-After header
5xx / network errorRetried 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.