Build posterly
KINDAreplaces $15/mosaves $180/yrback to the verdict
A local scheduler whose hard problems are publishing exactly once across a crash, discovering each network's real limits instead of hard-coding them, and exposing a tool surface an agent can drive without being able to publish unreviewed.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the scheduler and the local tool surface.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - have readyfree if you already have one, otherwise a few pounds a month
Why A scheduler asleep at the scheduled time silently misses every post, and a laptop is asleep most of the day.
Get it A small always-on box on your network, or a cheap virtual machine.
Verify
The tick runs unattended across a full day and the log shows no gaps - API keyfree
Why It is the first adapter and the only one whose limits genuinely vary by server, which is what forces the design.
Get it Create an application in your account preferences and copy the access token.
Verify
The instance configuration endpoint returns a character limit and a post publishes - API keyfree
Why The second adapter, and the one that proves the interface was worth defining.
Get it Create an app password in your account settings; it is not your login password.
Verify
A session is created and a post publishes with a caller-supplied record key - API keyfree
Why The third adapter, with a different shape again, which is the point.
Get it Create a bot with the BotFather and add it to a channel as an administrator.
Verify
A message reaches the channel - decidefree
Why Deciding what an agent may do after the tools exist means deciding it from whatever was convenient to build.
Get it Write down that no callable tool publishes, that approval is a human or the schedule, and that every call is logged.
Verify
A test walks every tool handler and finds no publish path
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- accounts(id, network, handle, credential_ref, connected_at, limits_json, limits_fetched_at, revoked_at) · limits are fetched from the network, not typed in - posts(id, created_by, kind, state, approved_by, approved_at, scheduled_for_utc, scheduled_zone) · state is draft, queued, claimed, sent, failed or dead - variants(post_id, account_id, text, media_json, idempotency_key, remote_id, sent_at) · one row per network, each with its own text and its own key - attempts(id, variant_id, at, outcome, http_status, retry_after_s, error_text) · every send attempt, including the ones that appeared to hang - agent_calls(id, at, tool, arguments_json, result, post_id) · the proposed text is stored verbatim, successes and failures alike - slots(account_id, weekday, local_time, zone) · posting windows in local time with the zone, never as a fixed offset - The idempotency key is minted when the variant is queued and never regenerated, so every retry of that send carries the key the first attempt carried.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/posterly.db | Accounts, posts, variants, attempts and the agent call log. |
MEDIA_DIR | required | ./data/media | Attached images, resized per network at queue time. |
MASTODON_TOKENsecret | required | mast-...redacted | The first adapter. Scoped to write statuses and read the instance configuration. |
BLUESKY_APP_PASSWORDsecret | optional | xxxx-xxxx-xxxx-xxxx | An app password, never the account password. |
TELEGRAM_BOT_TOKENsecret | optional | 1234567:AA...redacted | The bot must be an administrator of the target channel. |
MODEL_API_KEYsecret | optional | sk-...redacted | Caption drafting only. Without it the app schedules and publishes as normal. |
MODEL_MONTHLY_CEILING | required | 3.00 | Drafting stops at this figure; scheduling and publishing continue. |
PROBE_INTERVAL_MINUTES | required | 60 | How often each connection is checked, which decides how early a revoked token is noticed. |
The build, in order
One adapter, and limits you read rather than assume
A Mastodon account connects and the app knows that instance's real limits.
The second network is what the interface is for, and it arrives in phase three.
Files
src/adapters/base.jssrc/adapters/mastodon.jsA limit you typed in is a limit that will be wrong on someone else's instance.
Files
src/adapters/mastodon.jssrc/accounts.jsCatching it at send time means catching it when nobody is watching.
Files
src/validate.jstests/limits.test.js
done when · tick each as it passeswatch out- Hard-coding five hundred characters because that is Mastodon's documented default rejects perfectly good posts on an instance that allows more, and counting the raw string is wrong in any case: the same instance configuration publishes how many characters each link is assumed to occupy regardless of its real length, so a post with two long links is shorter than your counter believes. Checked on the Mastodon API documentation on 15 September 2026. Read the configuration at connect time, store all three figures with the time they were read, and refresh them on a schedule.
Publishing exactly once, across a crash
A variant is sent once even if the process dies at the worst possible moment.
The claim is what makes a second tick harmless.
Files
src/queue.jstests/exactly-once.test.jsA key regenerated on retry is not an idempotency key.
Files
src/queue.jsA silent infinite retry is how a stuck post stays invisible for a week.
Files
src/retry.jssrc/ui/queue.ejs
done when · tick each as it passeswatch out- Sending first and marking the row sent afterwards double-posts whenever the process is killed between the two, which on a home machine is an ordinary Tuesday, and the duplicate is visible to everyone before you notice. Claim the row with a lease first, carry an idempotency key minted at queue time through every retry, and treat an expired claim as needing a check against the network rather than a resend.
A second and third network, and text that differs
Bluesky and a Telegram channel publish through the same interface with their own text.
It is the only idempotency you get on that network.
Files
src/adapters/bluesky.jsTest it with an emoji in front of the link, which is the case that breaks.
Files
src/richtext.jstests/facets.test.jsCheck each network's published limits; they are not the same shape.
Files
src/ratelimit.jsdocs/network-limits.md
done when · tick each as it passeswatch out- Bluesky's rich text facets index into the post text by byte offset over its encoding, so computing them from JavaScript string positions puts every link and mention in the wrong place the moment a post contains an emoji or an accented character, and the post publishes looking fine in your database and broken on the network. Encode the text first, compute offsets on the bytes, and check the current lexicon rather than trusting this sentence.
Tools an agent can call, and none of them publish
An agent drafts, queues and schedules; a person or the clock publishes.
Absent beats disabled, because absent cannot be configured on.
Files
src/agent/tools.jstests/no-publish-tool.test.jsWhen something odd goes out, the proposed text is the only evidence.
Files
src/agent/log.jsRender the per-network text exactly as it will appear.
Files
src/ui/approve.ejssrc/posts.js
done when · tick each as it passeswatch out- Adding a publish tool 'just for testing' and gating it behind a flag means the flag is on in the only environment that matters, and an agent that misreads an instruction posts to a real account with your name on it. Do not implement the tool at all, so that no configuration can enable it, and make the approval screen the only path from draft to queued.
Credentials, media and leavingproduct builder
A revoked token is noticed before a scheduled post misses, and everything exports.
Not at send time, which is too late to fix it.
Files
src/probe.jstests/probe.test.jsRather than turning it into a stream of individual failures.
Files
src/queue.jsIt is the record of what was proposed, which outlives the app.
Files
src/export.jstests/roundtrip.test.js
done when · tick each as it passeswatch out- Discovering a revoked token at send time turns one authorisation problem into a stream of individual post failures spread over days, and by the time you look, the week's schedule has quietly emptied. Probe the connection on a timer, hold the queue for that account when the probe fails, and say on the front page which account needs reconnecting.
That is the whole plan for posterly. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Networks gated behind business verification.
- A learned voice model.
- Per-network video transcoding.
- Client approval portals and multi-brand workspaces.
- 18 maintained platform integrations, including approval-gated ones (Instagram, LinkedIn, TikTok, Facebook, Google Business Profile)
- a voice model trained on your actual post history and what performed well, not a static prompt template
- Google Business Profile review replies and photo management
- client approval portal and content-plan review for agencies managing multiple brands
- per-platform video/image transcoding tuned to each network's limits
- a hosted MCP server + REST API built for agents, not bolted on after the fact
- A dry run mode that renders every variant exactly as it would publish and sends nothing
- Per-account posting windows that respect a quiet period, computed in the account's zone
- A weekly digest of what published, what died and what an agent proposed but nobody approved
Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.