Build posterly

KINDAreplaces $15/mosaves $180/yrback to the verdict

0%0 of 18 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort weekendthe files for this build are in the project pack

RuntimeNode with Express and better-sqlite3 on a machine that is always onAdaptersOne file per network behind a single interface, with capability limits read at connect timeQueueClaim with a lease before sending, idempotency key minted at queue timeAgent surfaceRead and draft tools only, over local HTTP, with every call loggedMediaFiles on disk beside the database, resized per network at queue time

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/posterly.dbAccounts, posts, variants, attempts and the agent call log.
MEDIA_DIRrequired./data/mediaAttached images, resized per network at queue time.
MASTODON_TOKENsecretrequiredmast-...redactedThe first adapter. Scoped to write statuses and read the instance configuration.
BLUESKY_APP_PASSWORDsecretoptionalxxxx-xxxx-xxxx-xxxxAn app password, never the account password.
TELEGRAM_BOT_TOKENsecretoptional1234567:AA...redactedThe bot must be an administrator of the target channel.
MODEL_API_KEYsecretoptionalsk-...redactedCaption drafting only. Without it the app schedules and publishes as normal.
MODEL_MONTHLY_CEILINGrequired3.00Drafting stops at this figure; scheduling and publishing continue.
PROBE_INTERVAL_MINUTESrequired60How often each connection is checked, which decides how early a revoked token is noticed.

The build, in order

  1. One adapter, and limits you read rather than assume

    A Mastodon account connects and the app knows that instance's real limits.

    1. The second network is what the interface is for, and it arrives in phase three.

      Files src/adapters/base.jssrc/adapters/mastodon.js

    2. A limit you typed in is a limit that will be wrong on someone else's instance.

      Files src/adapters/mastodon.jssrc/accounts.js

    3. Catching it at send time means catching it when nobody is watching.

      Files src/validate.jstests/limits.test.js

    done when · tick each as it passes
    watch 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.
  2. Publishing exactly once, across a crash

    A variant is sent once even if the process dies at the worst possible moment.

    1. The claim is what makes a second tick harmless.

      Files src/queue.jstests/exactly-once.test.js

    2. A key regenerated on retry is not an idempotency key.

      Files src/queue.js

    3. A 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 passes
    watch 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.
  3. A second and third network, and text that differs

    Bluesky and a Telegram channel publish through the same interface with their own text.

    1. It is the only idempotency you get on that network.

      Files src/adapters/bluesky.js

    2. Test it with an emoji in front of the link, which is the case that breaks.

      Files src/richtext.jstests/facets.test.js

    3. Check each network's published limits; they are not the same shape.

      Files src/ratelimit.jsdocs/network-limits.md

    done when · tick each as it passes
    watch 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.
  4. Tools an agent can call, and none of them publish

    An agent drafts, queues and schedules; a person or the clock publishes.

    1. Absent beats disabled, because absent cannot be configured on.

      Files src/agent/tools.jstests/no-publish-tool.test.js

    2. When something odd goes out, the proposed text is the only evidence.

      Files src/agent/log.js

    3. Render the per-network text exactly as it will appear.

      Files src/ui/approve.ejssrc/posts.js

    done when · tick each as it passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.