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
}
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
}
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:readcan inspect permitted workspace data; -
mcp:fullcan 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_operationaccepts only operations classified as read-only; -
execute_operationaccepts 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
}
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.
Top comments (0)