Documentation
The content engine

Social

The social module connects accounts on Mastodon, LinkedIn and X, and publishes posts to them: at once, at a set time, or in the next free slot of a weekly schedule. It is the Go module smeldr.dev/social, and the release binary includes it.

Turn it on

In the release binary, set ENABLE_SOCIAL. Each platform's app settings are then kept in the database: an admin token sets them once with the MCP tool create_platform_config. (MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET and MASTODON_INSTANCE_URL are an older way to set Mastodon's, still read when the database has none.)

In your own Go program:

import (
    "smeldr.dev/mcp"
    "smeldr.dev/social"
)

srv := social.New(db, social.Config{Secret: []byte(secret)})
srv.Register(app)
defer srv.Stop()

mcpSrv := mcp.New(app,
    mcp.WithModule(srv.PostModule()),
    mcp.WithModule(srv.CredentialModule()),
    mcp.WithModule(srv.ConfigModule()),
    mcp.WithModule(srv.ScheduleModule()),
)

Stop lets the scheduler finish what it is publishing.

Connect an account

  1. Set the platform's app settings, from the app you registered with the platform:

json {"platform": "mastodon", "client_id": "...", "client_secret": "...", "redirect_url": "https://smeldr.example.com/oauth/mastodon/callback", "instance_url": "https://mastodon.social"}

create_platform_config needs platform, client_id, client_secret and redirect_url. Mastodon also needs instance_url. scope and success_url (where the browser goes after connecting) are optional.

  1. Call create_social_credential with the platform (and instance_url for Mastodon). It answers with a redirect_url on the platform.
  1. Open that URL in a browser and allow the app. The platform sends the browser back to /oauth/<platform>/callback, and the account is saved as a credential. The link works once: using it again is refused with invalid or expired OAuth state.
PlatformScope it asks forNotes
mastodonwrite:statuses write:mediaany instance, named by instance_url
linkedinopenid profile w_member_social
xtweet.read users.read tweet.write offline.access media.writewith PKCE

Tokens are stored encrypted with a key derived from SECRET. list_social_credentials shows the connected accounts.

Posts

create_scheduled_post needs credential_id and body. Its other fields:

  • platform: mastodon (the default), linkedin or x;
  • scheduled_at: an RFC 3339 time, which makes the post scheduled;
  • status: queued puts it in the credential's slot queue (see below);
  • media_url and alt_text: an image to attach, by its HTTPS URL.

Without scheduled_at or status, a post starts as a draft. publish_scheduled_post publishes a post at once:

{"slug": "01a1afda-f9c7-7ded-82fb-c559291f969d", "status": "published"}

The scheduler publishes a scheduled post when its time comes, checking at least once a minute. A post that fails is tried again after 30 seconds, 2 minutes, 10 minutes and an hour, and is then failed.

How long a post may be depends on the platform. X allows 280 characters, and a longer post fails. LinkedIn's text is cut at 3,000 characters. Mastodon's limit is the instance's own.

The slot queue

Instead of a time, a post can wait for the next slot in its credential's weekly schedule. Create the schedule with create_publication_schedule, with the slots as a JSON string:

{"credential_id": "01a1afda-f985-7c6e-be11-3680820af4ee",
 "slots": "[{\"weekday\":1,\"time\":\"09:00\",\"timezone\":\"Europe/Copenhagen\"}]"}
  • weekday is 0 (Sunday) to 6 (Saturday), time is HH:MM on a 24-hour clock, and timezone is an IANA name.
  • A credential has at most one schedule. A second is refused; change the first with update_publication_schedule.
  • A schedule is active or paused. A paused schedule keeps its queue.

When a slot comes, the oldest queued post of that credential is published. If the server was down when slots passed, it catches up with one post per missed slot, and at most as many as the schedule has slots.

REST

GET /social/posts lists the posts, with a bearer token. ?status=queued filters by state: draft, scheduled, queued, published, failed or archived.

A credential that is still used by posts cannot be deleted:

Conflict: credential 01a1afda-f985-7c6e-be11-3680820af4ee is used by 4 post(s); delete them or move them to another credential first

MCP tools

ToolsFor
create_platform_config, list_platform_configs, get_platform_configa platform's app settings (admin). Calling create_platform_config again replaces a platform's settings. The client secret is never returned: a listing shows client_secret_set instead
create_social_credential, list_social_credentials, get_social_credential, delete_social_credentialconnected accounts
create_scheduled_post, list_scheduled_posts, get_scheduled_post, update_scheduled_post, publish_scheduled_post, schedule_scheduled_post, archive_scheduled_post, delete_scheduled_postposts
create_publication_schedule, list_publication_schedules, get_publication_schedule, update_publication_schedule, delete_publication_scheduleslot schedules

smeldr-cli social covers the same ground from a shell; see smeldr-cli.

Sending events to an agent (Go)

In a Go program, AddRoutes sends a JSON POST to an HTTPS URL when a content item is published, scheduled, archived or deleted:

srv.AddRoutes(app,
    social.OnPublish("Post", "https://agent.example.com/hook"),
)

Each request carries X-Smeldr-Signature: sha256= and the hex HMAC-SHA256 of the body, keyed with the app's secret. The same value is also sent as X-Forge-Signature, which is deprecated and will be removed: verify X-Smeldr-Signature. A request that fails is tried again after 30 seconds, 2 minutes, 10 minutes and an hour. A 4xx answer other than 429 is not retried, and a 429 waits for its Retry-After.