A social post looks like a string until you try to send it to more than one platform.

That was roughly where OpenPost started. There was some source content, a list of accounts, and a queue that would publish it later. For a normal text post, that model is fine.

Then I started adding real formats.

X may need a thread. LinkedIn can take a PDF document. Instagram wants a carousel. YouTube needs a title, description, thumbnail and privacy setting. Even two accounts on the same platform may need different copy or media.

At that point, the “post” was no longer one thing. I could either keep adding exceptions to the original model or admit that I was modelling it wrong.

I chose the second option.

One idea, several actual posts

OpenPost now separates a publication from its renditions.

The publication is the shared intent: the idea, internal title, source text, content profile, source URL and schedule.

A simplified version looks like this:

type Publication struct {
    ID             string
    WorkspaceID    string
    Title          string
    ContentProfile string
    SourceText     string
    SourceURL      string
    Status         string
    ScheduledAt    time.Time
    ActualRunAt    time.Time
}

Enter fullscreen mode Exit fullscreen mode

The important field here is SourceText. It is the starting point for the person or agent preparing the campaign. OpenPost does not assume that it is already a valid payload for every provider.

Each destination account gets a rendition:

type Rendition struct {
    ID              string
    PublicationID   string
    SocialAccountID string
    Platform        string
    Profile         string
    Body            string
    Title           string
    Description     string
    SettingsJSON    string
    Status          string
    ExternalID      string
    ExternalURL     string
    ErrorMessage    string
}

Enter fullscreen mode Exit fullscreen mode

I attach renditions to accounts rather than only platforms. That distinction matters more than I expected. My personal LinkedIn account and a company LinkedIn page are both “LinkedIn,” but they are not necessarily supposed to receive the same post.

Media also belongs at this level. A rendition may need its own order, role, alt text or video thumbnail timestamp. A flat list of attachment IDs stopped being enough quite quickly.

This model creates more tables. It also removes a lot of increasingly ugly provider-specific logic from the source post itself, which is a trade I am happy with.

The source is not the thing that gets validated

One bug that is easy to create here is validating the source and assuming every output is now safe to publish.

It is not.

A rendition can replace the source copy and media. That means the effective rendition is what needs to satisfy the provider's limits. A valid source does not help if the X rendition is too long, or if the Instagram rendition replaces its images with something Instagram cannot accept.

OpenPost uses content profiles such as short_text, thread, link_share, image_post, carousel, story, short_video and long_video. The profile describes the general shape. The provider adapter then checks the exact rules for the chosen account and its final rendition.

This is more work than a universal content string, but at least the errors happen before a job reaches a provider API.

Then I added agents

The next problem was access.

I wanted an AI agent to inspect the workspace and prepare destination-specific drafts. I did not want to hand the agent decrypted provider credentials or give every connection publishing access by default.

OpenPost exposes two MCP scopes:

  • mcp:read can inspect permitted workspace data;
  • mcp:full can also create, edit, upload, schedule and publish.

This is enforced on the server. A read-only client does not receive the mutation executor during tool discovery. Mutation operations are removed from search results. Even if a client has cached the name of an older direct mutation tool, the server still rejects the call.

I also split the MCP execution surface itself:

  • query_operation accepts only operations classified as read-only;
  • execute_operation accepts state-changing or external operations.

The point is to give clients one reliable place to put an approval boundary. They do not have to guess whether something called prepare_post is secretly going to publish it.

There is an important caveat: OpenPost does not enforce a human approval ceremony around every full-access token. If you give an agent mcp:full and approve its mutation calls, it can publish. The server makes the authority clear and keeps provider credentials private. It cannot choose your trust model for you.

I did not want Redis in the default install

Scheduling should survive the HTTP request that created it. It should also survive a restart.

Using an unmanaged goroutine was never a serious option, so OpenPost stores jobs in the same configured database:

type Job struct {
    ID          string
    Type        string
    Payload     string
    Status      string
    RunAt       time.Time
    Attempts    int
    MaxAttempts int
    LastError   string
    LockedAt    time.Time
    LockedBy    string
}

Enter fullscreen mode Exit fullscreen mode

Workers claim due jobs atomically and keep the lock alive while they work. If a process dies, another worker eventually puts the stale job back into the pending queue. The attempts and errors remain visible instead of disappearing into a log line.

SQLite is the default, while PostgreSQL is supported for larger and hosted deployments. Keeping the queue portable across both databases is a bit annoying. I still prefer owning that complexity in OpenPost over making every self-hoster deploy Redis just to schedule a post.

The awkward parts

The publication/rendition model did not magically make the application simple.

Inheritance is easy to get wrong. Media needs three different states: inherit the source, replace the source, or intentionally use no media. Those cannot all collapse into an empty array.

Rescheduling also touches more than one row. The publication, renditions, queue jobs and lifecycle history need to stay consistent. The UI has to make it clear which account you are editing without feeling like a database administration tool.

There is also old code. OpenPost still has compatibility paths for the original post model while I move the rest of the product to format-first publications. Supporting both is ugly. Breaking every existing client in one release would be uglier.

The main lesson for me was that provider differences do not disappear when the product hides them. They come back as late validation errors, confusing overrides and if platform == ... branches spread across the codebase.

I would rather model the differences directly and then work on making that model feel simple in the interface.

Trying it

OpenPost is AGPL-3.0-only and ships as one Go binary or Docker container with the Svelte frontend embedded. It uses SQLite and local media by default, supports PostgreSQL and S3-compatible storage, and does not require Redis.

The current release is v1.1.3. The self-hosting guide covers HTTPS, storage, backups and provider setup.

I am still conservative about provider claims. Having an adapter in the repository does not prove that every OAuth permission, app-review state, quota and media path works for every real account. The docs separate implemented and preview paths instead of turning them into one large platform number.

If you have built something similar, I would be interested in how you handled inheritance between the shared source and the provider payloads. That part has taken more thought than most of the API integrations.