Vibecode posterly
track this build4 phases, 12 steps, beginner friendly0%A basic compose-queue-publish loop against open-API networks (Mastodon, Bluesky, Telegram) is a weekend build, same as any scheduler. What's specific to posterly and harder to fake is the agent-native layer: an MCP server exposing tools like generate_captions, get_learned_voice, and find_available_slot so a coding agent can draft and post on your behalf, plus a voice model that's actually learned from your posting history and its own performance data rather than a one-off few-shot prompt. Add Google Business Profile review management, per-platform video transcoding, and client approval workflows for agencies, and the moat is upkeep and depth, not the initial compose/publish loop.
You are building a lean indie version of posterly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # posterly · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express and better-sqlite3 on a machine that is always on | a scheduler that is asleep at the scheduled time is not a scheduler | | Adapters | One file per network behind a single interface, with capability limits read at connect time | every network disagrees about length, media and rate, and the disagreements change | | Queue | Claim with a lease before sending, idempotency key minted at queue time | a process that dies between sending and recording will otherwise post twice | | Agent surface | Read and draft tools only, over local HTTP, with every call logged | the publish authority stays with a person or with the schedule | | Media | Files on disk beside the database, resized per network at queue time | transcoding at send time turns a timing problem into a timing and CPU problem | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the scheduler and the local tool surface. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A machine that stays awake** · free 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 - [ ] **A Mastodon access token on an instance you use** · free - 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 - [ ] **A Bluesky app password** · free - 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 - [ ] **A Telegram bot token and a channel it can post to** (optional) · free - 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 - [ ] **Written agent authority** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for posterly, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · posterly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local social scheduler with an agent-facing API that publishes only to networks whose APIs are genuinely open to replace posterly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An agent that can publish is an agent that can publish a mistake to an audience, and a post is not undoable. A deletion a minute later has already been seen, quoted and screenshotted. So the authority split is fixed and is not a setting. Tools an agent can call may read, draft, queue and schedule. No tool an agent can call may publish. Publishing happens when a human approves a draft, or when the scheduler reaches an approved draft's time. An agent that wants something posted queues it and says so. This is not a limitation to work around later. It is the reason the thing is safe to point at an account with your name on it, and it is worth the extra screen. Every agent call is logged with the exact text it proposed, the tool it used, and what happened, because when something strange goes out the only useful question is what was asked for and by what. Keep the log even for calls that failed. And do not add the networks whose terms you would have to bend to reach. Automating a platform that gates its posting API behind business verification, by driving its web interface instead, is against its terms and gets the account removed rather than rate limited. ### Stack (fixed, do not substitute) - Runtime: Node with Express and better-sqlite3 on a machine that is always on · a scheduler that is asleep at the scheduled time is not a scheduler - Adapters: One file per network behind a single interface, with capability limits read at connect time · every network disagrees about length, media and rate, and the disagreements change - Queue: Claim with a lease before sending, idempotency key minted at queue time · a process that dies between sending and recording will otherwise post twice - Agent surface: Read and draft tools only, over local HTTP, with every call logged · the publish authority stays with a person or with the schedule - Media: Files on disk beside the database, resized per network at queue time · transcoding at send time turns a timing problem into a timing and CPU problem ### Data model (create this before Phase 1) - 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. ### Phase 1 · One adapter, and limits you read rather than assume Build: Define an adapter interface with connect, fetchLimits, publish and describeError. Implement Mastodon first. On connect, read the instance configuration endpoint and store the character limit, the media count and the characters each link is assumed to occupy, with the time they were read. Refuse to queue a variant over the stored limit, quoting that limit in the message. Trap: 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. Done when: Connecting an account stores its character limit, media count and per-link character figure with a fetched-at time, checked in the database. A variant over the stored limit is refused at queue time with the actual limit in the message, tested. Length counting uses the instance's per-link figure rather than the literal string length, tested with a post containing two long links. No number from any network is written in the source, verified by review. Do not build yet: No scheduling, sending, other networks or agent tools yet. ### Phase 2 · Publishing exactly once, across a crash Build: Mint an idempotency key when a variant is queued. The tick claims due variants with a lease and a claim timestamp before any network call, sends with the key, then records the remote id. A claim whose lease has expired is recoverable only after checking whether the post landed. Failures retry with backoff to a bounded count, then move to a dead state that stays visible. Trap: 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. Done when: Killing the process between the network call and the database write leaves exactly one published post, tested with an injected fault and a fake adapter. A retry sends the same idempotency key the first attempt used, asserted in the attempt log. A variant that fails the bounded retry count lands in a dead state that appears in the interface and is never retried automatically. Two ticks running concurrently publish a due variant once, tested. Do not build yet: No second network, per-network text or agent tools yet. ### Phase 3 · A second and third network, and text that differs Build: Add adapters for Bluesky and for a Telegram channel. Give each variant its own text and media. For Bluesky, supply your own record key on create so a retry cannot produce a second record, and compute rich text facet offsets against the encoded bytes rather than string positions. Read the current lexicon and the published rate limits for each network before choosing a tick interval. Trap: 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. Done when: One post publishes to three networks with three different texts and three remote ids, tested end to end against fakes. A Bluesky create carries a caller-supplied record key and repeating it does not produce a second record, tested. A post containing an emoji before a link publishes with the link intact, tested against a byte-offset fixture. Each adapter's rate budget is read from configuration and a send that would exceed it waits rather than failing. Do not build yet: No agent tools, approval flow or export yet. ### Phase 4 · Tools an agent can call, and none of them publish Build: Expose a small local tool surface: list accounts and their limits, find the next free slot, read recent published posts as voice examples, create a draft, and queue a draft for a time. There is no publish tool. Log every call with its arguments verbatim. An approval screen shows each draft exactly as it will appear per network, with the account and the time, and one control to approve. Trap: 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. Done when: No code path reachable from the tool surface publishes to a network, asserted by a test that walks the tool handlers. Every tool call appears in the log with its arguments and result, including calls that errored, checked after a scripted session. The approval screen renders the exact per-network text and the resolved local send time. A draft cannot change state to queued except through the approval path, tested. Do not build yet: No credential rotation, media retention or export yet. ### Phase 5 · Credentials, media and leaving Build: Probe each account on a schedule and mark it revoked with a visible banner when the network refuses. Queued posts for a revoked account hold rather than failing one by one. Put a ceiling on the caption model's monthly spend. Expire media after a retention period while keeping the rows. Export posts, variants, attempts and agent calls as CSV and reimport them into an empty database. Trap: 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. Done when: Revoking a token on the network is detected by the probe within the configured interval and the account is flagged before the next scheduled send, tested against a fake returning an authorisation failure. Queued posts for a revoked account hold and resume after reconnection without losing their scheduled order, tested. Reaching the monthly model ceiling disables caption drafting and leaves scheduling working. Export and reimport round-trip every table including the agent call log. Do not build yet: Nothing after this. ### Out of scope (and why) - Networks that gate posting behind business verification, including the large ones. Driving their web interface instead of their API is against their terms and ends in a removed account rather than a rate limit. - A learned voice model. Passing your recent posts as examples is a prompt, not a model, and calling it training would be a claim about something you have not built. - Video transcoding per network, which is a real pipeline with real storage rather than a resize call. - Client approval portals and multi-brand workspaces, which need their own authentication and an approval state machine of their own. ### README must contain - That no tool an agent can call publishes, and why that split is the reason this is safe to point at a real account - That every network limit is read from the network at connect time and stored with the date, never typed into the source - That the idempotency key is minted at queue time and reused on every retry, and what the claim lease is for - That rich text offsets on Bluesky are byte positions, with the emoji test case named - Which networks are deliberately absent and that the reason is their terms, not the effort ===== AGENTS.md ===== # Agent instructions · posterly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with Express and better-sqlite3 on a machine that is always on, One file per network behind a single interface, with capability limits read at connect time, Claim with a lease before sending, idempotency key minted at queue time, Read and draft tools only, over local HTTP, with every call logged, Files on disk beside the database, resized per network at queue time. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · posterly 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One adapter, and limits you read rather than assume A Mastodon account connects and the app knows that instance's real limits. ### Steps 1. Define the adapter interface before the first adapter The second network is what the interface is for, and it arrives in phase three. Files: `src/adapters/base.js`, `src/adapters/mastodon.js` 2. Fetch limits at connect time and store them with a timestamp A limit you typed in is a limit that will be wrong on someone else's instance. Files: `src/adapters/mastodon.js`, `src/accounts.js` 3. Validate at queue time against the stored limits Catching it at send time means catching it when nobody is watching. Files: `src/validate.js`, `tests/limits.test.js` ### Done when - [ ] Connect stores all three limits with a fetched-at time - [ ] An over-length variant is refused with the real limit quoted - [ ] Length counting uses the instance's per-link figure - [ ] No network limit is hard-coded in source ### 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. ## Phase 2 · Publishing exactly once, across a crash A variant is sent once even if the process dies at the worst possible moment. ### Steps 1. Claim with a lease before any network call The claim is what makes a second tick harmless. Files: `src/queue.js`, `tests/exactly-once.test.js` 2. Mint the idempotency key at queue time, never at send time A key regenerated on retry is not an idempotency key. Files: `src/queue.js` 3. Give up loudly into a dead state A silent infinite retry is how a stuck post stays invisible for a week. Files: `src/retry.js`, `src/ui/queue.ejs` ### Done when - [ ] A fault between send and write leaves one published post - [ ] Retries carry the original idempotency key - [ ] Bounded failures reach a visible dead state - [ ] Concurrent ticks publish once ### 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. ## Phase 3 · A second and third network, and text that differs Bluesky and a Telegram channel publish through the same interface with their own text. ### Steps 1. Supply your own record key on the Bluesky create It is the only idempotency you get on that network. Files: `src/adapters/bluesky.js` 2. Compute facet offsets on encoded bytes Test it with an emoji in front of the link, which is the case that breaks. Files: `src/richtext.js`, `tests/facets.test.js` 3. Give each adapter a rate budget it waits on Check each network's published limits; they are not the same shape. Files: `src/ratelimit.js`, `docs/network-limits.md` ### Done when - [ ] One post reaches three networks with three texts - [ ] A repeated Bluesky create makes one record - [ ] An emoji before a link does not move the link - [ ] A send over the rate budget waits rather than fails ### 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. ## Phase 4 · Tools an agent can call, and none of them publish An agent drafts, queues and schedules; a person or the clock publishes. ### Steps 1. Ship the tool surface with no publish handler Absent beats disabled, because absent cannot be configured on. Files: `src/agent/tools.js`, `tests/no-publish-tool.test.js` 2. Log arguments verbatim, successes and failures When something odd goes out, the proposed text is the only evidence. Files: `src/agent/log.js` 3. Make the approval screen the only draft-to-queued transition Render the per-network text exactly as it will appear. Files: `src/ui/approve.ejs`, `src/posts.js` ### Done when - [ ] No tool handler reaches a network publish - [ ] Every agent call is logged with verbatim arguments - [ ] Approval renders per-network text and local time - [ ] Draft to queued happens only through approval ### 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. ## Not in this build - Networks gated behind business verification. - A learned voice model. - Per-network video transcoding. - Client approval portals and multi-brand workspaces. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Accounts, posts, variants, attempts and the agent call log. DATABASE_PATH=./data/posterly.db # Required. Attached images, resized per network at queue time. MEDIA_DIR=./data/media # Required · secret. The first adapter. Scoped to write statuses and read the instance configuration. MASTODON_TOKEN=mast-...redacted # Optional · secret. An app password, never the account password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Optional · secret. The bot must be an administrator of the target channel. TELEGRAM_BOT_TOKEN=1234567:AA...redacted # Optional · secret. Caption drafting only. Without it the app schedules and publishes as normal. MODEL_API_KEY=sk-...redacted # Required. Drafting stops at this figure; scheduling and publishing continue. MODEL_MONTHLY_CEILING=3.00 # Required. How often each connection is checked, which decides how early a revoked token is noticed. PROBE_INTERVAL_MINUTES=60
You are building a lean indie version of posterly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # posterly · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express and better-sqlite3 on a machine that is always on | a scheduler that is asleep at the scheduled time is not a scheduler | | Adapters | One file per network behind a single interface, with capability limits read at connect time | every network disagrees about length, media and rate, and the disagreements change | | Queue | Claim with a lease before sending, idempotency key minted at queue time | a process that dies between sending and recording will otherwise post twice | | Agent surface | Read and draft tools only, over local HTTP, with every call logged | the publish authority stays with a person or with the schedule | | Media | Files on disk beside the database, resized per network at queue time | transcoding at send time turns a timing problem into a timing and CPU problem | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the scheduler and the local tool surface. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A machine that stays awake** · free 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 - [ ] **A Mastodon access token on an instance you use** · free - 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 - [ ] **A Bluesky app password** · free - 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 - [ ] **A Telegram bot token and a channel it can post to** (optional) · free - 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 - [ ] **Written agent authority** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for posterly, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · posterly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local social scheduler with an agent-facing API that publishes only to networks whose APIs are genuinely open to replace posterly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An agent that can publish is an agent that can publish a mistake to an audience, and a post is not undoable. A deletion a minute later has already been seen, quoted and screenshotted. So the authority split is fixed and is not a setting. Tools an agent can call may read, draft, queue and schedule. No tool an agent can call may publish. Publishing happens when a human approves a draft, or when the scheduler reaches an approved draft's time. An agent that wants something posted queues it and says so. This is not a limitation to work around later. It is the reason the thing is safe to point at an account with your name on it, and it is worth the extra screen. Every agent call is logged with the exact text it proposed, the tool it used, and what happened, because when something strange goes out the only useful question is what was asked for and by what. Keep the log even for calls that failed. And do not add the networks whose terms you would have to bend to reach. Automating a platform that gates its posting API behind business verification, by driving its web interface instead, is against its terms and gets the account removed rather than rate limited. ### Stack (fixed, do not substitute) - Runtime: Node with Express and better-sqlite3 on a machine that is always on · a scheduler that is asleep at the scheduled time is not a scheduler - Adapters: One file per network behind a single interface, with capability limits read at connect time · every network disagrees about length, media and rate, and the disagreements change - Queue: Claim with a lease before sending, idempotency key minted at queue time · a process that dies between sending and recording will otherwise post twice - Agent surface: Read and draft tools only, over local HTTP, with every call logged · the publish authority stays with a person or with the schedule - Media: Files on disk beside the database, resized per network at queue time · transcoding at send time turns a timing problem into a timing and CPU problem ### Data model (create this before Phase 1) - 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. ### Phase 1 · One adapter, and limits you read rather than assume Build: Define an adapter interface with connect, fetchLimits, publish and describeError. Implement Mastodon first. On connect, read the instance configuration endpoint and store the character limit, the media count and the characters each link is assumed to occupy, with the time they were read. Refuse to queue a variant over the stored limit, quoting that limit in the message. Trap: 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. Done when: Connecting an account stores its character limit, media count and per-link character figure with a fetched-at time, checked in the database. A variant over the stored limit is refused at queue time with the actual limit in the message, tested. Length counting uses the instance's per-link figure rather than the literal string length, tested with a post containing two long links. No number from any network is written in the source, verified by review. Do not build yet: No scheduling, sending, other networks or agent tools yet. ### Phase 2 · Publishing exactly once, across a crash Build: Mint an idempotency key when a variant is queued. The tick claims due variants with a lease and a claim timestamp before any network call, sends with the key, then records the remote id. A claim whose lease has expired is recoverable only after checking whether the post landed. Failures retry with backoff to a bounded count, then move to a dead state that stays visible. Trap: 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. Done when: Killing the process between the network call and the database write leaves exactly one published post, tested with an injected fault and a fake adapter. A retry sends the same idempotency key the first attempt used, asserted in the attempt log. A variant that fails the bounded retry count lands in a dead state that appears in the interface and is never retried automatically. Two ticks running concurrently publish a due variant once, tested. Do not build yet: No second network, per-network text or agent tools yet. ### Phase 3 · A second and third network, and text that differs Build: Add adapters for Bluesky and for a Telegram channel. Give each variant its own text and media. For Bluesky, supply your own record key on create so a retry cannot produce a second record, and compute rich text facet offsets against the encoded bytes rather than string positions. Read the current lexicon and the published rate limits for each network before choosing a tick interval. Trap: 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. Done when: One post publishes to three networks with three different texts and three remote ids, tested end to end against fakes. A Bluesky create carries a caller-supplied record key and repeating it does not produce a second record, tested. A post containing an emoji before a link publishes with the link intact, tested against a byte-offset fixture. Each adapter's rate budget is read from configuration and a send that would exceed it waits rather than failing. Do not build yet: No agent tools, approval flow or export yet. ### Phase 4 · Tools an agent can call, and none of them publish Build: Expose a small local tool surface: list accounts and their limits, find the next free slot, read recent published posts as voice examples, create a draft, and queue a draft for a time. There is no publish tool. Log every call with its arguments verbatim. An approval screen shows each draft exactly as it will appear per network, with the account and the time, and one control to approve. Trap: 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. Done when: No code path reachable from the tool surface publishes to a network, asserted by a test that walks the tool handlers. Every tool call appears in the log with its arguments and result, including calls that errored, checked after a scripted session. The approval screen renders the exact per-network text and the resolved local send time. A draft cannot change state to queued except through the approval path, tested. Do not build yet: No credential rotation, media retention or export yet. ### Phase 5 · Credentials, media and leaving Build: Probe each account on a schedule and mark it revoked with a visible banner when the network refuses. Queued posts for a revoked account hold rather than failing one by one. Put a ceiling on the caption model's monthly spend. Expire media after a retention period while keeping the rows. Export posts, variants, attempts and agent calls as CSV and reimport them into an empty database. Trap: 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. Done when: Revoking a token on the network is detected by the probe within the configured interval and the account is flagged before the next scheduled send, tested against a fake returning an authorisation failure. Queued posts for a revoked account hold and resume after reconnection without losing their scheduled order, tested. Reaching the monthly model ceiling disables caption drafting and leaves scheduling working. Export and reimport round-trip every table including the agent call log. Do not build yet: Nothing after this. ### Out of scope (and why) - Networks that gate posting behind business verification, including the large ones. Driving their web interface instead of their API is against their terms and ends in a removed account rather than a rate limit. - A learned voice model. Passing your recent posts as examples is a prompt, not a model, and calling it training would be a claim about something you have not built. - Video transcoding per network, which is a real pipeline with real storage rather than a resize call. - Client approval portals and multi-brand workspaces, which need their own authentication and an approval state machine of their own. ### README must contain - That no tool an agent can call publishes, and why that split is the reason this is safe to point at a real account - That every network limit is read from the network at connect time and stored with the date, never typed into the source - That the idempotency key is minted at queue time and reused on every retry, and what the claim lease is for - That rich text offsets on Bluesky are byte positions, with the emoji test case named - Which networks are deliberately absent and that the reason is their terms, not the effort ===== AGENTS.md ===== # Agent instructions · posterly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with Express and better-sqlite3 on a machine that is always on, One file per network behind a single interface, with capability limits read at connect time, Claim with a lease before sending, idempotency key minted at queue time, Read and draft tools only, over local HTTP, with every call logged, Files on disk beside the database, resized per network at queue time. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · posterly 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One adapter, and limits you read rather than assume A Mastodon account connects and the app knows that instance's real limits. ### Steps 1. Define the adapter interface before the first adapter The second network is what the interface is for, and it arrives in phase three. Files: `src/adapters/base.js`, `src/adapters/mastodon.js` 2. Fetch limits at connect time and store them with a timestamp A limit you typed in is a limit that will be wrong on someone else's instance. Files: `src/adapters/mastodon.js`, `src/accounts.js` 3. Validate at queue time against the stored limits Catching it at send time means catching it when nobody is watching. Files: `src/validate.js`, `tests/limits.test.js` ### Done when - [ ] Connect stores all three limits with a fetched-at time - [ ] An over-length variant is refused with the real limit quoted - [ ] Length counting uses the instance's per-link figure - [ ] No network limit is hard-coded in source ### 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. ## Phase 2 · Publishing exactly once, across a crash A variant is sent once even if the process dies at the worst possible moment. ### Steps 1. Claim with a lease before any network call The claim is what makes a second tick harmless. Files: `src/queue.js`, `tests/exactly-once.test.js` 2. Mint the idempotency key at queue time, never at send time A key regenerated on retry is not an idempotency key. Files: `src/queue.js` 3. Give up loudly into a dead state A silent infinite retry is how a stuck post stays invisible for a week. Files: `src/retry.js`, `src/ui/queue.ejs` ### Done when - [ ] A fault between send and write leaves one published post - [ ] Retries carry the original idempotency key - [ ] Bounded failures reach a visible dead state - [ ] Concurrent ticks publish once ### 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. ## Phase 3 · A second and third network, and text that differs Bluesky and a Telegram channel publish through the same interface with their own text. ### Steps 1. Supply your own record key on the Bluesky create It is the only idempotency you get on that network. Files: `src/adapters/bluesky.js` 2. Compute facet offsets on encoded bytes Test it with an emoji in front of the link, which is the case that breaks. Files: `src/richtext.js`, `tests/facets.test.js` 3. Give each adapter a rate budget it waits on Check each network's published limits; they are not the same shape. Files: `src/ratelimit.js`, `docs/network-limits.md` ### Done when - [ ] One post reaches three networks with three texts - [ ] A repeated Bluesky create makes one record - [ ] An emoji before a link does not move the link - [ ] A send over the rate budget waits rather than fails ### 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. ## Phase 4 · Tools an agent can call, and none of them publish An agent drafts, queues and schedules; a person or the clock publishes. ### Steps 1. Ship the tool surface with no publish handler Absent beats disabled, because absent cannot be configured on. Files: `src/agent/tools.js`, `tests/no-publish-tool.test.js` 2. Log arguments verbatim, successes and failures When something odd goes out, the proposed text is the only evidence. Files: `src/agent/log.js` 3. Make the approval screen the only draft-to-queued transition Render the per-network text exactly as it will appear. Files: `src/ui/approve.ejs`, `src/posts.js` ### Done when - [ ] No tool handler reaches a network publish - [ ] Every agent call is logged with verbatim arguments - [ ] Approval renders per-network text and local time - [ ] Draft to queued happens only through approval ### 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. ## Not in this build - Networks gated behind business verification. - A learned voice model. - Per-network video transcoding. - Client approval portals and multi-brand workspaces. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Accounts, posts, variants, attempts and the agent call log. DATABASE_PATH=./data/posterly.db # Required. Attached images, resized per network at queue time. MEDIA_DIR=./data/media # Required · secret. The first adapter. Scoped to write statuses and read the instance configuration. MASTODON_TOKEN=mast-...redacted # Optional · secret. An app password, never the account password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Optional · secret. The bot must be an administrator of the target channel. TELEGRAM_BOT_TOKEN=1234567:AA...redacted # Optional · secret. Caption drafting only. Without it the app schedules and publishes as normal. MODEL_API_KEY=sk-...redacted # Required. Drafting stops at this figure; scheduling and publishing continue. MODEL_MONTHLY_CEILING=3.00 # Required. How often each connection is checked, which decides how early a revoked token is noticed. PROBE_INTERVAL_MINUTES=60
You are building a production product version of posterly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # posterly · product brief ## Problem A basic compose-queue-publish loop against open-API networks (Mastodon, Bluesky, Telegram) is a weekend build, same as any scheduler. What's specific to posterly and harder to fake is the agent-native layer: an MCP server exposing tools like generate_captions, get_learned_voice, and find_available_slot so a coding agent can draft and post on your behalf, plus a voice model that's actually learned from your posting history and its own performance data rather than a one-off few-shot prompt. Add Google Business Profile review management, per-platform video transcoding, and client approval workflows for agencies, and the moat is upkeep and depth, not the initial compose/publish loop. ## Product outcome A scheduler that publishes exactly once across a crash, knows each network's real limits because it asked them, lets an agent draft and queue but never publish, and notices a dead credential before a scheduled post misses. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - OAuth/API access per social network - always-on box for the scheduler - database - media storage - LLM API key for caption generation ## Explicit non-goals for v1 - 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 ## Success criteria - No tool handler is confirmed to reach a network publish - A fault between send and write is confirmed to leave one published post - No network limit is confirmed present in the source - A revoked credential is confirmed flagged before the next scheduled send - Export and reimport are confirmed to round-trip the agent call log ===== BRIEF.md ===== # Build brief · posterly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local social scheduler with an agent-facing API that publishes only to networks whose APIs are genuinely open to replace posterly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An agent that can publish is an agent that can publish a mistake to an audience, and a post is not undoable. A deletion a minute later has already been seen, quoted and screenshotted. So the authority split is fixed and is not a setting. Tools an agent can call may read, draft, queue and schedule. No tool an agent can call may publish. Publishing happens when a human approves a draft, or when the scheduler reaches an approved draft's time. An agent that wants something posted queues it and says so. This is not a limitation to work around later. It is the reason the thing is safe to point at an account with your name on it, and it is worth the extra screen. Every agent call is logged with the exact text it proposed, the tool it used, and what happened, because when something strange goes out the only useful question is what was asked for and by what. Keep the log even for calls that failed. And do not add the networks whose terms you would have to bend to reach. Automating a platform that gates its posting API behind business verification, by driving its web interface instead, is against its terms and gets the account removed rather than rate limited. ### Stack (fixed, do not substitute) - Runtime: Node with Express and better-sqlite3 on a machine that is always on · a scheduler that is asleep at the scheduled time is not a scheduler - Adapters: One file per network behind a single interface, with capability limits read at connect time · every network disagrees about length, media and rate, and the disagreements change - Queue: Claim with a lease before sending, idempotency key minted at queue time · a process that dies between sending and recording will otherwise post twice - Agent surface: Read and draft tools only, over local HTTP, with every call logged · the publish authority stays with a person or with the schedule - Media: Files on disk beside the database, resized per network at queue time · transcoding at send time turns a timing problem into a timing and CPU problem ### Data model (create this before Phase 1) - 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. ### Phase 1 · One adapter, and limits you read rather than assume Build: Define an adapter interface with connect, fetchLimits, publish and describeError. Implement Mastodon first. On connect, read the instance configuration endpoint and store the character limit, the media count and the characters each link is assumed to occupy, with the time they were read. Refuse to queue a variant over the stored limit, quoting that limit in the message. Trap: 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. Done when: Connecting an account stores its character limit, media count and per-link character figure with a fetched-at time, checked in the database. A variant over the stored limit is refused at queue time with the actual limit in the message, tested. Length counting uses the instance's per-link figure rather than the literal string length, tested with a post containing two long links. No number from any network is written in the source, verified by review. Do not build yet: No scheduling, sending, other networks or agent tools yet. ### Phase 2 · Publishing exactly once, across a crash Build: Mint an idempotency key when a variant is queued. The tick claims due variants with a lease and a claim timestamp before any network call, sends with the key, then records the remote id. A claim whose lease has expired is recoverable only after checking whether the post landed. Failures retry with backoff to a bounded count, then move to a dead state that stays visible. Trap: 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. Done when: Killing the process between the network call and the database write leaves exactly one published post, tested with an injected fault and a fake adapter. A retry sends the same idempotency key the first attempt used, asserted in the attempt log. A variant that fails the bounded retry count lands in a dead state that appears in the interface and is never retried automatically. Two ticks running concurrently publish a due variant once, tested. Do not build yet: No second network, per-network text or agent tools yet. ### Phase 3 · A second and third network, and text that differs Build: Add adapters for Bluesky and for a Telegram channel. Give each variant its own text and media. For Bluesky, supply your own record key on create so a retry cannot produce a second record, and compute rich text facet offsets against the encoded bytes rather than string positions. Read the current lexicon and the published rate limits for each network before choosing a tick interval. Trap: 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. Done when: One post publishes to three networks with three different texts and three remote ids, tested end to end against fakes. A Bluesky create carries a caller-supplied record key and repeating it does not produce a second record, tested. A post containing an emoji before a link publishes with the link intact, tested against a byte-offset fixture. Each adapter's rate budget is read from configuration and a send that would exceed it waits rather than failing. Do not build yet: No agent tools, approval flow or export yet. ### Phase 4 · Tools an agent can call, and none of them publish Build: Expose a small local tool surface: list accounts and their limits, find the next free slot, read recent published posts as voice examples, create a draft, and queue a draft for a time. There is no publish tool. Log every call with its arguments verbatim. An approval screen shows each draft exactly as it will appear per network, with the account and the time, and one control to approve. Trap: 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. Done when: No code path reachable from the tool surface publishes to a network, asserted by a test that walks the tool handlers. Every tool call appears in the log with its arguments and result, including calls that errored, checked after a scripted session. The approval screen renders the exact per-network text and the resolved local send time. A draft cannot change state to queued except through the approval path, tested. Do not build yet: No credential rotation, media retention or export yet. ### Phase 5 · Credentials, media and leaving Build: Probe each account on a schedule and mark it revoked with a visible banner when the network refuses. Queued posts for a revoked account hold rather than failing one by one. Put a ceiling on the caption model's monthly spend. Expire media after a retention period while keeping the rows. Export posts, variants, attempts and agent calls as CSV and reimport them into an empty database. Trap: 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. Done when: Revoking a token on the network is detected by the probe within the configured interval and the account is flagged before the next scheduled send, tested against a fake returning an authorisation failure. Queued posts for a revoked account hold and resume after reconnection without losing their scheduled order, tested. Reaching the monthly model ceiling disables caption drafting and leaves scheduling working. Export and reimport round-trip every table including the agent call log. Do not build yet: Nothing after this. ### Out of scope (and why) - Networks that gate posting behind business verification, including the large ones. Driving their web interface instead of their API is against their terms and ends in a removed account rather than a rate limit. - A learned voice model. Passing your recent posts as examples is a prompt, not a model, and calling it training would be a claim about something you have not built. - Video transcoding per network, which is a real pipeline with real storage rather than a resize call. - Client approval portals and multi-brand workspaces, which need their own authentication and an approval state machine of their own. ### README must contain - That no tool an agent can call publishes, and why that split is the reason this is safe to point at a real account - That every network limit is read from the network at connect time and stored with the date, never typed into the source - That the idempotency key is minted at queue time and reused on every retry, and what the claim lease is for - That rich text offsets on Bluesky are byte positions, with the emoji test case named - Which networks are deliberately absent and that the reason is their terms, not the effort ===== ARCHITECTURE.md ===== # Architecture · posterly ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express and better-sqlite3 on a machine that is always on | a scheduler that is asleep at the scheduled time is not a scheduler | | Adapters | One file per network behind a single interface, with capability limits read at connect time | every network disagrees about length, media and rate, and the disagreements change | | Queue | Claim with a lease before sending, idempotency key minted at queue time | a process that dies between sending and recording will otherwise post twice | | Agent surface | Read and draft tools only, over local HTTP, with every call logged | the publish authority stays with a person or with the schedule | | Media | Files on disk beside the database, resized per network at queue time | transcoding at send time turns a timing problem into a timing and CPU problem | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Adapters | Per-network publish, limits and error naming. | Any network with an open posting API. Not one you would have to drive through its web interface. | | Queue | Claims, leases, idempotency keys and retries. | Any store with a conditional update. Not a plain timer loop. | | Agent surface | Read and draft tools, and the call log. | Any transport. Never one that adds a publish tool. | | Approval | The only draft-to-queued transition. | Not swappable for an automatic approval, which is the same as no approval. | | Probe | Credential health and queue holds. | Any schedule, provided it runs more often than your posting cadence. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Accounts, posts, variants, attempts and the agent call log. - `MEDIA_DIR` · required · Attached images, resized per network at queue time. - `MASTODON_TOKEN` · required, secret · The first adapter. Scoped to write statuses and read the instance configuration. - `BLUESKY_APP_PASSWORD` · optional, secret · An app password, never the account password. - `TELEGRAM_BOT_TOKEN` · optional, secret · The bot must be an administrator of the target channel. - `MODEL_API_KEY` · optional, secret · Caption drafting only. Without it the app schedules and publishes as normal. - `MODEL_MONTHLY_CEILING` · required · Drafting stops at this figure; scheduling and publishing continue. - `PROBE_INTERVAL_MINUTES` · required · How often each connection is checked, which decides how early a revoked token is noticed. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · posterly product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with Express and better-sqlite3 on a machine that is always on, One file per network behind a single interface, with capability limits read at connect time, Claim with a lease before sending, idempotency key minted at queue time, Read and draft tools only, over local HTTP, with every call logged, Files on disk beside the database, resized per network at queue time. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · posterly Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One adapter, and limits you read rather than assume A Mastodon account connects and the app knows that instance's real limits. ### Steps 1. Define the adapter interface before the first adapter The second network is what the interface is for, and it arrives in phase three. Files: `src/adapters/base.js`, `src/adapters/mastodon.js` 2. Fetch limits at connect time and store them with a timestamp A limit you typed in is a limit that will be wrong on someone else's instance. Files: `src/adapters/mastodon.js`, `src/accounts.js` 3. Validate at queue time against the stored limits Catching it at send time means catching it when nobody is watching. Files: `src/validate.js`, `tests/limits.test.js` ### Done when - [ ] Connect stores all three limits with a fetched-at time - [ ] An over-length variant is refused with the real limit quoted - [ ] Length counting uses the instance's per-link figure - [ ] No network limit is hard-coded in source ### 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. ## M2 · Publishing exactly once, across a crash A variant is sent once even if the process dies at the worst possible moment. ### Steps 1. Claim with a lease before any network call The claim is what makes a second tick harmless. Files: `src/queue.js`, `tests/exactly-once.test.js` 2. Mint the idempotency key at queue time, never at send time A key regenerated on retry is not an idempotency key. Files: `src/queue.js` 3. Give up loudly into a dead state A silent infinite retry is how a stuck post stays invisible for a week. Files: `src/retry.js`, `src/ui/queue.ejs` ### Done when - [ ] A fault between send and write leaves one published post - [ ] Retries carry the original idempotency key - [ ] Bounded failures reach a visible dead state - [ ] Concurrent ticks publish once ### 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. ## M3 · A second and third network, and text that differs Bluesky and a Telegram channel publish through the same interface with their own text. ### Steps 1. Supply your own record key on the Bluesky create It is the only idempotency you get on that network. Files: `src/adapters/bluesky.js` 2. Compute facet offsets on encoded bytes Test it with an emoji in front of the link, which is the case that breaks. Files: `src/richtext.js`, `tests/facets.test.js` 3. Give each adapter a rate budget it waits on Check each network's published limits; they are not the same shape. Files: `src/ratelimit.js`, `docs/network-limits.md` ### Done when - [ ] One post reaches three networks with three texts - [ ] A repeated Bluesky create makes one record - [ ] An emoji before a link does not move the link - [ ] A send over the rate budget waits rather than fails ### 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. ## M4 · Tools an agent can call, and none of them publish An agent drafts, queues and schedules; a person or the clock publishes. ### Steps 1. Ship the tool surface with no publish handler Absent beats disabled, because absent cannot be configured on. Files: `src/agent/tools.js`, `tests/no-publish-tool.test.js` 2. Log arguments verbatim, successes and failures When something odd goes out, the proposed text is the only evidence. Files: `src/agent/log.js` 3. Make the approval screen the only draft-to-queued transition Render the per-network text exactly as it will appear. Files: `src/ui/approve.ejs`, `src/posts.js` ### Done when - [ ] No tool handler reaches a network publish - [ ] Every agent call is logged with verbatim arguments - [ ] Approval renders per-network text and local time - [ ] Draft to queued happens only through approval ### 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. ## M5 · Credentials, media and leaving (production only) A revoked token is noticed before a scheduled post misses, and everything exports. ### Steps 1. Probe connections on a timer and flag the account Not at send time, which is too late to fix it. Files: `src/probe.js`, `tests/probe.test.js` 2. Hold the queue for a revoked account Rather than turning it into a stream of individual failures. Files: `src/queue.js` 3. Export everything, agent log included It is the record of what was proposed, which outlives the app. Files: `src/export.js`, `tests/roundtrip.test.js` ### Done when - [ ] A revoked token is flagged before the next send - [ ] Queued posts hold and resume in order - [ ] The model ceiling stops drafting, not scheduling - [ ] Export and reimport round-trip every table ### Watch 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. ===== OPERATIONS.md ===== # Operations · posterly ## Backup The database nightly, media weekly. The attempt log and the agent call log are the parts you cannot reconstruct, and they are the two you will want when something unexpected has been published. ## Restore Restore, then check that no variant is stuck in the claimed state before starting the tick, because a restored claim with a live lease will be skipped until it expires. Confirm remote ids for recently sent variants before letting the tick run. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Posts published against posts scheduled, the dead-state count, probe failures per account, and the age of the oldest queued variant. A growing oldest-queued age is the first sign of a credential problem. ## Incident checklist The incident is a duplicate or a wrong post on a real account. The attempt log gives you the timing, the agent call log gives you what was proposed, and the idempotency key tells you whether a duplicate came from your retry or from the network. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No tool handler is confirmed to reach a network publish - [ ] A fault between send and write is confirmed to leave one published post - [ ] No network limit is confirmed present in the source - [ ] A revoked credential is confirmed flagged before the next scheduled send - [ ] Export and reimport are confirmed to round-trip the agent call log ## Launch constraint Do not market omitted posterly capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Accounts, posts, variants, attempts and the agent call log. DATABASE_PATH=./data/posterly.db # Required. Attached images, resized per network at queue time. MEDIA_DIR=./data/media # Required · secret. The first adapter. Scoped to write statuses and read the instance configuration. MASTODON_TOKEN=mast-...redacted # Optional · secret. An app password, never the account password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Optional · secret. The bot must be an administrator of the target channel. TELEGRAM_BOT_TOKEN=1234567:AA...redacted # Optional · secret. Caption drafting only. Without it the app schedules and publishes as normal. MODEL_API_KEY=sk-...redacted # Required. Drafting stops at this figure; scheduling and publishing continue. MODEL_MONTHLY_CEILING=3.00 # Required. How often each connection is checked, which decides how early a revoked token is noticed. PROBE_INTERVAL_MINUTES=60
# posterly · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express and better-sqlite3 on a machine that is always on | a scheduler that is asleep at the scheduled time is not a scheduler | | Adapters | One file per network behind a single interface, with capability limits read at connect time | every network disagrees about length, media and rate, and the disagreements change | | Queue | Claim with a lease before sending, idempotency key minted at queue time | a process that dies between sending and recording will otherwise post twice | | Agent surface | Read and draft tools only, over local HTTP, with every call logged | the publish authority stays with a person or with the schedule | | Media | Files on disk beside the database, resized per network at queue time | transcoding at send time turns a timing problem into a timing and CPU problem | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the scheduler and the local tool surface. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A machine that stays awake** · free 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 - [ ] **A Mastodon access token on an instance you use** · free - 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 - [ ] **A Bluesky app password** · free - 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 - [ ] **A Telegram bot token and a channel it can post to** (optional) · free - 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 - [ ] **Written agent authority** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for posterly, and the README should say so rather than pretend.
# Build brief · posterly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local social scheduler with an agent-facing API that publishes only to networks whose APIs are genuinely open to replace posterly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An agent that can publish is an agent that can publish a mistake to an audience, and a post is not undoable. A deletion a minute later has already been seen, quoted and screenshotted. So the authority split is fixed and is not a setting. Tools an agent can call may read, draft, queue and schedule. No tool an agent can call may publish. Publishing happens when a human approves a draft, or when the scheduler reaches an approved draft's time. An agent that wants something posted queues it and says so. This is not a limitation to work around later. It is the reason the thing is safe to point at an account with your name on it, and it is worth the extra screen. Every agent call is logged with the exact text it proposed, the tool it used, and what happened, because when something strange goes out the only useful question is what was asked for and by what. Keep the log even for calls that failed. And do not add the networks whose terms you would have to bend to reach. Automating a platform that gates its posting API behind business verification, by driving its web interface instead, is against its terms and gets the account removed rather than rate limited. ### Stack (fixed, do not substitute) - Runtime: Node with Express and better-sqlite3 on a machine that is always on · a scheduler that is asleep at the scheduled time is not a scheduler - Adapters: One file per network behind a single interface, with capability limits read at connect time · every network disagrees about length, media and rate, and the disagreements change - Queue: Claim with a lease before sending, idempotency key minted at queue time · a process that dies between sending and recording will otherwise post twice - Agent surface: Read and draft tools only, over local HTTP, with every call logged · the publish authority stays with a person or with the schedule - Media: Files on disk beside the database, resized per network at queue time · transcoding at send time turns a timing problem into a timing and CPU problem ### Data model (create this before Phase 1) - 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. ### Phase 1 · One adapter, and limits you read rather than assume Build: Define an adapter interface with connect, fetchLimits, publish and describeError. Implement Mastodon first. On connect, read the instance configuration endpoint and store the character limit, the media count and the characters each link is assumed to occupy, with the time they were read. Refuse to queue a variant over the stored limit, quoting that limit in the message. Trap: 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. Done when: Connecting an account stores its character limit, media count and per-link character figure with a fetched-at time, checked in the database. A variant over the stored limit is refused at queue time with the actual limit in the message, tested. Length counting uses the instance's per-link figure rather than the literal string length, tested with a post containing two long links. No number from any network is written in the source, verified by review. Do not build yet: No scheduling, sending, other networks or agent tools yet. ### Phase 2 · Publishing exactly once, across a crash Build: Mint an idempotency key when a variant is queued. The tick claims due variants with a lease and a claim timestamp before any network call, sends with the key, then records the remote id. A claim whose lease has expired is recoverable only after checking whether the post landed. Failures retry with backoff to a bounded count, then move to a dead state that stays visible. Trap: 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. Done when: Killing the process between the network call and the database write leaves exactly one published post, tested with an injected fault and a fake adapter. A retry sends the same idempotency key the first attempt used, asserted in the attempt log. A variant that fails the bounded retry count lands in a dead state that appears in the interface and is never retried automatically. Two ticks running concurrently publish a due variant once, tested. Do not build yet: No second network, per-network text or agent tools yet. ### Phase 3 · A second and third network, and text that differs Build: Add adapters for Bluesky and for a Telegram channel. Give each variant its own text and media. For Bluesky, supply your own record key on create so a retry cannot produce a second record, and compute rich text facet offsets against the encoded bytes rather than string positions. Read the current lexicon and the published rate limits for each network before choosing a tick interval. Trap: 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. Done when: One post publishes to three networks with three different texts and three remote ids, tested end to end against fakes. A Bluesky create carries a caller-supplied record key and repeating it does not produce a second record, tested. A post containing an emoji before a link publishes with the link intact, tested against a byte-offset fixture. Each adapter's rate budget is read from configuration and a send that would exceed it waits rather than failing. Do not build yet: No agent tools, approval flow or export yet. ### Phase 4 · Tools an agent can call, and none of them publish Build: Expose a small local tool surface: list accounts and their limits, find the next free slot, read recent published posts as voice examples, create a draft, and queue a draft for a time. There is no publish tool. Log every call with its arguments verbatim. An approval screen shows each draft exactly as it will appear per network, with the account and the time, and one control to approve. Trap: 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. Done when: No code path reachable from the tool surface publishes to a network, asserted by a test that walks the tool handlers. Every tool call appears in the log with its arguments and result, including calls that errored, checked after a scripted session. The approval screen renders the exact per-network text and the resolved local send time. A draft cannot change state to queued except through the approval path, tested. Do not build yet: No credential rotation, media retention or export yet. ### Phase 5 · Credentials, media and leaving Build: Probe each account on a schedule and mark it revoked with a visible banner when the network refuses. Queued posts for a revoked account hold rather than failing one by one. Put a ceiling on the caption model's monthly spend. Expire media after a retention period while keeping the rows. Export posts, variants, attempts and agent calls as CSV and reimport them into an empty database. Trap: 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. Done when: Revoking a token on the network is detected by the probe within the configured interval and the account is flagged before the next scheduled send, tested against a fake returning an authorisation failure. Queued posts for a revoked account hold and resume after reconnection without losing their scheduled order, tested. Reaching the monthly model ceiling disables caption drafting and leaves scheduling working. Export and reimport round-trip every table including the agent call log. Do not build yet: Nothing after this. ### Out of scope (and why) - Networks that gate posting behind business verification, including the large ones. Driving their web interface instead of their API is against their terms and ends in a removed account rather than a rate limit. - A learned voice model. Passing your recent posts as examples is a prompt, not a model, and calling it training would be a claim about something you have not built. - Video transcoding per network, which is a real pipeline with real storage rather than a resize call. - Client approval portals and multi-brand workspaces, which need their own authentication and an approval state machine of their own. ### README must contain - That no tool an agent can call publishes, and why that split is the reason this is safe to point at a real account - That every network limit is read from the network at connect time and stored with the date, never typed into the source - That the idempotency key is minted at queue time and reused on every retry, and what the claim lease is for - That rich text offsets on Bluesky are byte positions, with the emoji test case named - Which networks are deliberately absent and that the reason is their terms, not the effort
# Agent instructions · posterly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with Express and better-sqlite3 on a machine that is always on, One file per network behind a single interface, with capability limits read at connect time, Claim with a lease before sending, idempotency key minted at queue time, Read and draft tools only, over local HTTP, with every call logged, Files on disk beside the database, resized per network at queue time. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · posterly 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One adapter, and limits you read rather than assume A Mastodon account connects and the app knows that instance's real limits. ### Steps 1. Define the adapter interface before the first adapter The second network is what the interface is for, and it arrives in phase three. Files: `src/adapters/base.js`, `src/adapters/mastodon.js` 2. Fetch limits at connect time and store them with a timestamp A limit you typed in is a limit that will be wrong on someone else's instance. Files: `src/adapters/mastodon.js`, `src/accounts.js` 3. Validate at queue time against the stored limits Catching it at send time means catching it when nobody is watching. Files: `src/validate.js`, `tests/limits.test.js` ### Done when - [ ] Connect stores all three limits with a fetched-at time - [ ] An over-length variant is refused with the real limit quoted - [ ] Length counting uses the instance's per-link figure - [ ] No network limit is hard-coded in source ### 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. ## Phase 2 · Publishing exactly once, across a crash A variant is sent once even if the process dies at the worst possible moment. ### Steps 1. Claim with a lease before any network call The claim is what makes a second tick harmless. Files: `src/queue.js`, `tests/exactly-once.test.js` 2. Mint the idempotency key at queue time, never at send time A key regenerated on retry is not an idempotency key. Files: `src/queue.js` 3. Give up loudly into a dead state A silent infinite retry is how a stuck post stays invisible for a week. Files: `src/retry.js`, `src/ui/queue.ejs` ### Done when - [ ] A fault between send and write leaves one published post - [ ] Retries carry the original idempotency key - [ ] Bounded failures reach a visible dead state - [ ] Concurrent ticks publish once ### 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. ## Phase 3 · A second and third network, and text that differs Bluesky and a Telegram channel publish through the same interface with their own text. ### Steps 1. Supply your own record key on the Bluesky create It is the only idempotency you get on that network. Files: `src/adapters/bluesky.js` 2. Compute facet offsets on encoded bytes Test it with an emoji in front of the link, which is the case that breaks. Files: `src/richtext.js`, `tests/facets.test.js` 3. Give each adapter a rate budget it waits on Check each network's published limits; they are not the same shape. Files: `src/ratelimit.js`, `docs/network-limits.md` ### Done when - [ ] One post reaches three networks with three texts - [ ] A repeated Bluesky create makes one record - [ ] An emoji before a link does not move the link - [ ] A send over the rate budget waits rather than fails ### 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. ## Phase 4 · Tools an agent can call, and none of them publish An agent drafts, queues and schedules; a person or the clock publishes. ### Steps 1. Ship the tool surface with no publish handler Absent beats disabled, because absent cannot be configured on. Files: `src/agent/tools.js`, `tests/no-publish-tool.test.js` 2. Log arguments verbatim, successes and failures When something odd goes out, the proposed text is the only evidence. Files: `src/agent/log.js` 3. Make the approval screen the only draft-to-queued transition Render the per-network text exactly as it will appear. Files: `src/ui/approve.ejs`, `src/posts.js` ### Done when - [ ] No tool handler reaches a network publish - [ ] Every agent call is logged with verbatim arguments - [ ] Approval renders per-network text and local time - [ ] Draft to queued happens only through approval ### 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. ## Not in this build - Networks gated behind business verification. - A learned voice model. - Per-network video transcoding. - Client approval portals and multi-brand workspaces. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Accounts, posts, variants, attempts and the agent call log. DATABASE_PATH=./data/posterly.db # Required. Attached images, resized per network at queue time. MEDIA_DIR=./data/media # Required · secret. The first adapter. Scoped to write statuses and read the instance configuration. MASTODON_TOKEN=mast-...redacted # Optional · secret. An app password, never the account password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Optional · secret. The bot must be an administrator of the target channel. TELEGRAM_BOT_TOKEN=1234567:AA...redacted # Optional · secret. Caption drafting only. Without it the app schedules and publishes as normal. MODEL_API_KEY=sk-...redacted # Required. Drafting stops at this figure; scheduling and publishing continue. MODEL_MONTHLY_CEILING=3.00 # Required. How often each connection is checked, which decides how early a revoked token is noticed. PROBE_INTERVAL_MINUTES=60
# posterly · product brief ## Problem A basic compose-queue-publish loop against open-API networks (Mastodon, Bluesky, Telegram) is a weekend build, same as any scheduler. What's specific to posterly and harder to fake is the agent-native layer: an MCP server exposing tools like generate_captions, get_learned_voice, and find_available_slot so a coding agent can draft and post on your behalf, plus a voice model that's actually learned from your posting history and its own performance data rather than a one-off few-shot prompt. Add Google Business Profile review management, per-platform video transcoding, and client approval workflows for agencies, and the moat is upkeep and depth, not the initial compose/publish loop. ## Product outcome A scheduler that publishes exactly once across a crash, knows each network's real limits because it asked them, lets an agent draft and queue but never publish, and notices a dead credential before a scheduled post misses. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - OAuth/API access per social network - always-on box for the scheduler - database - media storage - LLM API key for caption generation ## Explicit non-goals for v1 - 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 ## Success criteria - No tool handler is confirmed to reach a network publish - A fault between send and write is confirmed to leave one published post - No network limit is confirmed present in the source - A revoked credential is confirmed flagged before the next scheduled send - Export and reimport are confirmed to round-trip the agent call log
# Build brief · posterly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local social scheduler with an agent-facing API that publishes only to networks whose APIs are genuinely open to replace posterly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero An agent that can publish is an agent that can publish a mistake to an audience, and a post is not undoable. A deletion a minute later has already been seen, quoted and screenshotted. So the authority split is fixed and is not a setting. Tools an agent can call may read, draft, queue and schedule. No tool an agent can call may publish. Publishing happens when a human approves a draft, or when the scheduler reaches an approved draft's time. An agent that wants something posted queues it and says so. This is not a limitation to work around later. It is the reason the thing is safe to point at an account with your name on it, and it is worth the extra screen. Every agent call is logged with the exact text it proposed, the tool it used, and what happened, because when something strange goes out the only useful question is what was asked for and by what. Keep the log even for calls that failed. And do not add the networks whose terms you would have to bend to reach. Automating a platform that gates its posting API behind business verification, by driving its web interface instead, is against its terms and gets the account removed rather than rate limited. ### Stack (fixed, do not substitute) - Runtime: Node with Express and better-sqlite3 on a machine that is always on · a scheduler that is asleep at the scheduled time is not a scheduler - Adapters: One file per network behind a single interface, with capability limits read at connect time · every network disagrees about length, media and rate, and the disagreements change - Queue: Claim with a lease before sending, idempotency key minted at queue time · a process that dies between sending and recording will otherwise post twice - Agent surface: Read and draft tools only, over local HTTP, with every call logged · the publish authority stays with a person or with the schedule - Media: Files on disk beside the database, resized per network at queue time · transcoding at send time turns a timing problem into a timing and CPU problem ### Data model (create this before Phase 1) - 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. ### Phase 1 · One adapter, and limits you read rather than assume Build: Define an adapter interface with connect, fetchLimits, publish and describeError. Implement Mastodon first. On connect, read the instance configuration endpoint and store the character limit, the media count and the characters each link is assumed to occupy, with the time they were read. Refuse to queue a variant over the stored limit, quoting that limit in the message. Trap: 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. Done when: Connecting an account stores its character limit, media count and per-link character figure with a fetched-at time, checked in the database. A variant over the stored limit is refused at queue time with the actual limit in the message, tested. Length counting uses the instance's per-link figure rather than the literal string length, tested with a post containing two long links. No number from any network is written in the source, verified by review. Do not build yet: No scheduling, sending, other networks or agent tools yet. ### Phase 2 · Publishing exactly once, across a crash Build: Mint an idempotency key when a variant is queued. The tick claims due variants with a lease and a claim timestamp before any network call, sends with the key, then records the remote id. A claim whose lease has expired is recoverable only after checking whether the post landed. Failures retry with backoff to a bounded count, then move to a dead state that stays visible. Trap: 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. Done when: Killing the process between the network call and the database write leaves exactly one published post, tested with an injected fault and a fake adapter. A retry sends the same idempotency key the first attempt used, asserted in the attempt log. A variant that fails the bounded retry count lands in a dead state that appears in the interface and is never retried automatically. Two ticks running concurrently publish a due variant once, tested. Do not build yet: No second network, per-network text or agent tools yet. ### Phase 3 · A second and third network, and text that differs Build: Add adapters for Bluesky and for a Telegram channel. Give each variant its own text and media. For Bluesky, supply your own record key on create so a retry cannot produce a second record, and compute rich text facet offsets against the encoded bytes rather than string positions. Read the current lexicon and the published rate limits for each network before choosing a tick interval. Trap: 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. Done when: One post publishes to three networks with three different texts and three remote ids, tested end to end against fakes. A Bluesky create carries a caller-supplied record key and repeating it does not produce a second record, tested. A post containing an emoji before a link publishes with the link intact, tested against a byte-offset fixture. Each adapter's rate budget is read from configuration and a send that would exceed it waits rather than failing. Do not build yet: No agent tools, approval flow or export yet. ### Phase 4 · Tools an agent can call, and none of them publish Build: Expose a small local tool surface: list accounts and their limits, find the next free slot, read recent published posts as voice examples, create a draft, and queue a draft for a time. There is no publish tool. Log every call with its arguments verbatim. An approval screen shows each draft exactly as it will appear per network, with the account and the time, and one control to approve. Trap: 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. Done when: No code path reachable from the tool surface publishes to a network, asserted by a test that walks the tool handlers. Every tool call appears in the log with its arguments and result, including calls that errored, checked after a scripted session. The approval screen renders the exact per-network text and the resolved local send time. A draft cannot change state to queued except through the approval path, tested. Do not build yet: No credential rotation, media retention or export yet. ### Phase 5 · Credentials, media and leaving Build: Probe each account on a schedule and mark it revoked with a visible banner when the network refuses. Queued posts for a revoked account hold rather than failing one by one. Put a ceiling on the caption model's monthly spend. Expire media after a retention period while keeping the rows. Export posts, variants, attempts and agent calls as CSV and reimport them into an empty database. Trap: 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. Done when: Revoking a token on the network is detected by the probe within the configured interval and the account is flagged before the next scheduled send, tested against a fake returning an authorisation failure. Queued posts for a revoked account hold and resume after reconnection without losing their scheduled order, tested. Reaching the monthly model ceiling disables caption drafting and leaves scheduling working. Export and reimport round-trip every table including the agent call log. Do not build yet: Nothing after this. ### Out of scope (and why) - Networks that gate posting behind business verification, including the large ones. Driving their web interface instead of their API is against their terms and ends in a removed account rather than a rate limit. - A learned voice model. Passing your recent posts as examples is a prompt, not a model, and calling it training would be a claim about something you have not built. - Video transcoding per network, which is a real pipeline with real storage rather than a resize call. - Client approval portals and multi-brand workspaces, which need their own authentication and an approval state machine of their own. ### README must contain - That no tool an agent can call publishes, and why that split is the reason this is safe to point at a real account - That every network limit is read from the network at connect time and stored with the date, never typed into the source - That the idempotency key is minted at queue time and reused on every retry, and what the claim lease is for - That rich text offsets on Bluesky are byte positions, with the emoji test case named - Which networks are deliberately absent and that the reason is their terms, not the effort
# Architecture · posterly ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with Express and better-sqlite3 on a machine that is always on | a scheduler that is asleep at the scheduled time is not a scheduler | | Adapters | One file per network behind a single interface, with capability limits read at connect time | every network disagrees about length, media and rate, and the disagreements change | | Queue | Claim with a lease before sending, idempotency key minted at queue time | a process that dies between sending and recording will otherwise post twice | | Agent surface | Read and draft tools only, over local HTTP, with every call logged | the publish authority stays with a person or with the schedule | | Media | Files on disk beside the database, resized per network at queue time | transcoding at send time turns a timing problem into a timing and CPU problem | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Adapters | Per-network publish, limits and error naming. | Any network with an open posting API. Not one you would have to drive through its web interface. | | Queue | Claims, leases, idempotency keys and retries. | Any store with a conditional update. Not a plain timer loop. | | Agent surface | Read and draft tools, and the call log. | Any transport. Never one that adds a publish tool. | | Approval | The only draft-to-queued transition. | Not swappable for an automatic approval, which is the same as no approval. | | Probe | Credential health and queue holds. | Any schedule, provided it runs more often than your posting cadence. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Accounts, posts, variants, attempts and the agent call log. - `MEDIA_DIR` · required · Attached images, resized per network at queue time. - `MASTODON_TOKEN` · required, secret · The first adapter. Scoped to write statuses and read the instance configuration. - `BLUESKY_APP_PASSWORD` · optional, secret · An app password, never the account password. - `TELEGRAM_BOT_TOKEN` · optional, secret · The bot must be an administrator of the target channel. - `MODEL_API_KEY` · optional, secret · Caption drafting only. Without it the app schedules and publishes as normal. - `MODEL_MONTHLY_CEILING` · required · Drafting stops at this figure; scheduling and publishing continue. - `PROBE_INTERVAL_MINUTES` · required · How often each connection is checked, which decides how early a revoked token is noticed. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · posterly product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with Express and better-sqlite3 on a machine that is always on, One file per network behind a single interface, with capability limits read at connect time, Claim with a lease before sending, idempotency key minted at queue time, Read and draft tools only, over local HTTP, with every call logged, Files on disk beside the database, resized per network at queue time. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · posterly Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One adapter, and limits you read rather than assume A Mastodon account connects and the app knows that instance's real limits. ### Steps 1. Define the adapter interface before the first adapter The second network is what the interface is for, and it arrives in phase three. Files: `src/adapters/base.js`, `src/adapters/mastodon.js` 2. Fetch limits at connect time and store them with a timestamp A limit you typed in is a limit that will be wrong on someone else's instance. Files: `src/adapters/mastodon.js`, `src/accounts.js` 3. Validate at queue time against the stored limits Catching it at send time means catching it when nobody is watching. Files: `src/validate.js`, `tests/limits.test.js` ### Done when - [ ] Connect stores all three limits with a fetched-at time - [ ] An over-length variant is refused with the real limit quoted - [ ] Length counting uses the instance's per-link figure - [ ] No network limit is hard-coded in source ### 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. ## M2 · Publishing exactly once, across a crash A variant is sent once even if the process dies at the worst possible moment. ### Steps 1. Claim with a lease before any network call The claim is what makes a second tick harmless. Files: `src/queue.js`, `tests/exactly-once.test.js` 2. Mint the idempotency key at queue time, never at send time A key regenerated on retry is not an idempotency key. Files: `src/queue.js` 3. Give up loudly into a dead state A silent infinite retry is how a stuck post stays invisible for a week. Files: `src/retry.js`, `src/ui/queue.ejs` ### Done when - [ ] A fault between send and write leaves one published post - [ ] Retries carry the original idempotency key - [ ] Bounded failures reach a visible dead state - [ ] Concurrent ticks publish once ### 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. ## M3 · A second and third network, and text that differs Bluesky and a Telegram channel publish through the same interface with their own text. ### Steps 1. Supply your own record key on the Bluesky create It is the only idempotency you get on that network. Files: `src/adapters/bluesky.js` 2. Compute facet offsets on encoded bytes Test it with an emoji in front of the link, which is the case that breaks. Files: `src/richtext.js`, `tests/facets.test.js` 3. Give each adapter a rate budget it waits on Check each network's published limits; they are not the same shape. Files: `src/ratelimit.js`, `docs/network-limits.md` ### Done when - [ ] One post reaches three networks with three texts - [ ] A repeated Bluesky create makes one record - [ ] An emoji before a link does not move the link - [ ] A send over the rate budget waits rather than fails ### 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. ## M4 · Tools an agent can call, and none of them publish An agent drafts, queues and schedules; a person or the clock publishes. ### Steps 1. Ship the tool surface with no publish handler Absent beats disabled, because absent cannot be configured on. Files: `src/agent/tools.js`, `tests/no-publish-tool.test.js` 2. Log arguments verbatim, successes and failures When something odd goes out, the proposed text is the only evidence. Files: `src/agent/log.js` 3. Make the approval screen the only draft-to-queued transition Render the per-network text exactly as it will appear. Files: `src/ui/approve.ejs`, `src/posts.js` ### Done when - [ ] No tool handler reaches a network publish - [ ] Every agent call is logged with verbatim arguments - [ ] Approval renders per-network text and local time - [ ] Draft to queued happens only through approval ### 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. ## M5 · Credentials, media and leaving (production only) A revoked token is noticed before a scheduled post misses, and everything exports. ### Steps 1. Probe connections on a timer and flag the account Not at send time, which is too late to fix it. Files: `src/probe.js`, `tests/probe.test.js` 2. Hold the queue for a revoked account Rather than turning it into a stream of individual failures. Files: `src/queue.js` 3. Export everything, agent log included It is the record of what was proposed, which outlives the app. Files: `src/export.js`, `tests/roundtrip.test.js` ### Done when - [ ] A revoked token is flagged before the next send - [ ] Queued posts hold and resume in order - [ ] The model ceiling stops drafting, not scheduling - [ ] Export and reimport round-trip every table ### Watch 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.
# Operations · posterly ## Backup The database nightly, media weekly. The attempt log and the agent call log are the parts you cannot reconstruct, and they are the two you will want when something unexpected has been published. ## Restore Restore, then check that no variant is stuck in the claimed state before starting the tick, because a restored claim with a live lease will be skipped until it expires. Confirm remote ids for recently sent variants before letting the tick run. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Posts published against posts scheduled, the dead-state count, probe failures per account, and the age of the oldest queued variant. A growing oldest-queued age is the first sign of a credential problem. ## Incident checklist The incident is a duplicate or a wrong post on a real account. The attempt log gives you the timing, the agent call log gives you what was proposed, and the idempotency key tells you whether a duplicate came from your retry or from the network. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No tool handler is confirmed to reach a network publish - [ ] A fault between send and write is confirmed to leave one published post - [ ] No network limit is confirmed present in the source - [ ] A revoked credential is confirmed flagged before the next scheduled send - [ ] Export and reimport are confirmed to round-trip the agent call log ## Launch constraint Do not market omitted posterly capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Accounts, posts, variants, attempts and the agent call log. DATABASE_PATH=./data/posterly.db # Required. Attached images, resized per network at queue time. MEDIA_DIR=./data/media # Required · secret. The first adapter. Scoped to write statuses and read the instance configuration. MASTODON_TOKEN=mast-...redacted # Optional · secret. An app password, never the account password. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Optional · secret. The bot must be an administrator of the target channel. TELEGRAM_BOT_TOKEN=1234567:AA...redacted # Optional · secret. Caption drafting only. Without it the app schedules and publishes as normal. MODEL_API_KEY=sk-...redacted # Required. Drafting stops at this figure; scheduling and publishing continue. MODEL_MONTHLY_CEILING=3.00 # Required. How often each connection is checked, which decides how early a revoked token is noticed. PROBE_INTERVAL_MINUTES=60
$ choose a build depth, inspect the files, then open the complete pack in your agent
Anyone can wire an LLM to a caption box; keeping a voice model current against real engagement data, holding pre-approved OAuth apps for the platforms that gate access behind business verification, and exposing a stable MCP/API surface an agent can drive reliably are all ongoing work, not a one-time build. Agencies additionally pay for the client-review layer, which needs its own auth and approval state machine.
x18 maintained platform integrations, including approval-gated ones (Instagram, LinkedIn, TikTok, Facebook, Google Business Profile)
xa voice model trained on your actual post history and what performed well, not a static prompt template
xGoogle Business Profile review replies and photo management
xclient approval portal and content-plan review for agencies managing multiple brands
xper-platform video/image transcoding tuned to each network's limits
xa hosted MCP server + REST API built for agents, not bolted on after the fact
posterly pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $7 | $5.58 | 5 connected social accounts, 1 seat, 1 brand, 0 link-in-bio pages, 300 AI credits/month |
| pro | $15 | $12 | 12 connected social accounts, 3 seats, 5 brands, 5 link-in-bio pages, 800 AI credits/month |
free tierno free tier
billingmonthly + annual; 7-day trial and 7-day money-back guarantee
hidden costsThe MCP/API is a separate $3/month add-on; the Agency API is $29/month. X publishing can require separately priced X capacity because of X API costs.
verified 2026-08-14 · source ↗
Vibecode posterly
Kinda. The core of posterly is buildable in a weekend with the prompt on this page, but there are real gaps: 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. Read the honest list above before committing.
How much does posterly cost?
posterly costs about $15/month (Pro, checked 2026-07-30), which is $180 per year.
What do I lose by replacing posterly?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to posterly?
Yes: Postiz (Open-source social scheduler, self-hostable, covers much of the DIY core for open-API networks.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.