Vibecode BulkPublish
track this build4 phases, 12 steps, beginner friendly0%The part people picture is a weekend: a list of posts, a timer, one API call each. The part that eats the next six months is everything after the first successful post · staying authenticated to a dozen networks, learning each one's media rules the hard way, publishing that reports success and then quietly fails minutes later, retries that must never post twice, and a review process before some networks will accept a post at all. Build it for two accounts you control and it works. Build it for every network on the marketing page and you have taken a job.
You are building a lean indie version of BulkPublish. 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 ===== # BulkPublish · indie build A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one. 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 | One process on an always-on machine with a scheduler tick every minute | a scheduler that only runs when the laptop is open is a to-do list | | Storage | SQLite with the send state machine enforced in the schema | the duplicate-post bug is a database bug, so the database prevents it | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs with published limits and no review gate | | Limits | Fetched from each network at connect time and rechecked daily, never hard-coded | the limit you typed in is the one that changed last month | | Idempotency | The network's own key where it offers one, the state machine everywhere | belt and braces, because one of them has a one hour memory | | Media | Copied beside the database, checked and resized at import, deleted after a stated retention | a twelve megabyte photograph is a rejected post | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the scheduler and the web interface in one process. - Get it: python.org, or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **A machine that is always on** · a few pounds a month, or free if you own the box - Why: The scheduler tick has to fire at the slot, and a laptop that sleeps turns the queue into a list of things that did not happen. - Get it: A small VPS, a home server or a single-board computer with a static power supply. - Verify: A test row scheduled for three in the morning has sent by breakfast - [ ] **A Mastodon account with an application token** · free - Why: One of the two networks, connected the documented way with a token you can revoke. - Get it: Create an application in your account's development settings with write scope for statuses and media, and copy the access token. - Verify: The instance endpoint returns that server's limits with your token - [ ] **A Bluesky account with an app password** · free - Why: The other network, connected with an app password rather than your main one so it can be revoked alone. - Get it: Create an app password in your account settings and use it with your handle. - Verify: A session is created and your profile resolves - [ ] **A throwaway account on each network** · free - Why: Exactly-once and reconciliation are only testable by actually sending, and a duplicate on your real account is a duplicate your followers see. - Get it: A second account on each network that nobody follows, connected the same way. - Verify: Both test accounts are connected and a row sends to each - [ ] **Your slots, retention and clock change rule** · free - Why: Each is asserted by a test, and deciding them after seeing failures means choosing whatever passes. - Get it: Write down the posting slots per account, how long media is kept after sending, and what happens to a slot that falls in the daylight saving gap. - Verify: All three are in the configuration and named in the tests ## 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: - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. - staying connected · networks expire your auth and someone has to notice - knowing a post is malformed before the network rejects it, not after - an honest publish status when a network accepts the upload and fails minutes later - analytics pulled back per post - approvals, teams and multiple client workspaces - the platform review process that gates posting on some networks entirely If one of those is essential to you, that is the reason to keep paying for BulkPublish, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · BulkPublish 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 spreadsheet-first social scheduler that can never post the same row twice to replace BulkPublish. 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 Two rules, and the second is what makes the first possible. Never post twice. A retry after a timeout, a scheduler tick that ran twice, a crash between the network accepting a post and the database hearing about it: each of these produces a duplicate unless the row is locked before the network is touched. So a row moves to 'sending' in its own transaction before any call, a confirmed send is never retried, and a send whose outcome is unknown is settled by asking the network rather than by trying again. Only networks whose published terms allow what you are doing, with credentials obtained the way they document. Here that is Mastodon and Bluesky: open APIs, published limits, no review gate. No browser automation, no cookie-driven posting, no network whose rules you have not read, because a scheduler that gets an account suspended has cost more than it saved. ### Stack (fixed, do not substitute) - Runtime: One process on an always-on machine with a scheduler tick every minute · a scheduler that only runs when the laptop is open is a to-do list - Storage: SQLite with the send state machine enforced in the schema · the duplicate-post bug is a database bug, so the database prevents it - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs with published limits and no review gate - Limits: Fetched from each network at connect time and rechecked daily, never hard-coded · the limit you typed in is the one that changed last month - Idempotency: The network's own key where it offers one, the state machine everywhere · belt and braces, because one of them has a one hour memory - Media: Copied beside the database, checked and resized at import, deleted after a stated retention · a twelve megabyte photograph is a rejected post ### Data model (create this before Phase 1) - accounts(id, network, handle, credential_ref, limits_json, limits_fetched_at, timezone, state, paused_reason) - batches(id, filename, imported_at, rows_total, rows_accepted, report_json) - posts(id, batch_id, account_id, caption, media_ids_json, send_at, state, attempts, idempotency_key, remote_id, remote_url, sent_at, last_error) · state is queued, sending, sent, failed or needs_reconciliation - media(id, path, mime, bytes, width, height, sha256, alt_text, delete_after) - attempts(id, post_id, started_at, finished_at, outcome, response_excerpt) · every network call, including the ones that timed out - slots(account_id, day_of_week, time_local) · posting times in the account's own timezone - The state column is the lock: a row moves to sending in its own transaction before any network call, so a crash or a double tick cannot produce two posts, and a timed-out call leaves needs_reconciliation rather than a retry. ### Phase 1 · Importing a spreadsheet that tells you what is wrong Build: Fetch each network's limits at connect. Mastodon's instance endpoint reports that server's max_characters, max_media_attachments and image_size_limit; Bluesky's post lexicon caps text at 300 graphemes and 3,000 bytes and its image embed allows four images of up to 2,000,000 bytes each with alt text required (checked against the current lexicon, which has changed before). Parse the CSV, check every row per target network counting graphemes, show each failure beside its row, and import only the rows that pass. Trap: Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. Done when: Limits are fetched per account and shown with their fetch time. A caption over the grapheme limit is flagged at import with the count and the limit, tested with emoji. A row targeting a Mastodon server with a non-default limit is checked against that server's figure. An image over the blob limit is flagged before import rather than at send. Do not build yet: No scheduling, sending, media processing or history. ### Phase 2 · Slots in the account's timezone Build: Configure posting slots per account in that account's timezone. Assign each unscheduled row to the next free slot, store the instant, and show the local time beside it. Handle a slot that falls in a daylight saving gap or on the repeated hour, and a batch larger than a week of slots. Trap: Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. Done when: An unscheduled row takes the next free slot and no two rows share one. A slot on each clock change day resolves by the stated rule, tested with both transition dates. A batch larger than the available slots reports how far into the future it reaches before import. The stored instant and the displayed local time agree across the change. Do not build yet: No sending, media processing or history yet. ### Phase 3 · Sending exactly once Build: Each tick selects due rows one at a time and moves each to sending in its own transaction before any network call. Send a client-generated idempotency key: Mastodon honours an Idempotency-Key header for up to an hour (checked), and Bluesky's record creation should be checked for an equivalent before relying on one. A confirmed send is never retried. A timed-out call leaves the row in needs_reconciliation, and a later pass asks the network whether the post exists before deciding anything. Retry with backoff only failures that happened before the network could have accepted. Trap: Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row in needs_reconciliation that resolves to sent on the next pass, tested with a fake network. Two ticks running concurrently produce one send, tested. A confirmed send is never retried, asserted in the suite. Backoff applies only to pre-acceptance failures. Do not build yet: No media processing or history page yet. ### Phase 4 · Media that arrives whole Build: Copy media beside the database at import, check its type and dimensions, and resize or recompress to the fetched blob limit rather than a guessed one. Require alt text where the network requires it. Upload media first, wait until the network reports it ready where uploads are processed asynchronously, then post referencing it. Delete media after the stated retention once the row has sent. Trap: Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. Done when: An oversized image is resized at import to within the fetched limit and the result is recorded. A row missing alt text for a network that requires it is flagged at import. A post references media only after the network reports it ready, tested with a slow fake. Sent rows have their media deleted after the retention and the deletion is logged. Do not build yet: No history page or credential handling yet. ### Phase 5 · Knowing what actually landed Build: A history page showing each row's state, error, attempts and live URL, with retry offered only for failed rows. Verify a sent post by fetching it back. On the first authentication failure pause the account, stop scheduling its rows and alert once, rather than failing every row at its slot. Export the history. Bind to localhost and keep secrets in the environment. Trap: A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. Done when: The history page shows state, attempts, error and the live URL for every row. A sent post is verified by fetching it back, and a post that has since vanished is marked. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Retry is offered only for failed rows and never for sent or sending ones. Do not build yet: Nothing after this. ### Out of scope (and why) - X, whose API is pay-per-use with no free tier, at 0.015 USD per post and 0.200 USD per post carrying a link at the time of checking, and whose pricing has changed repeatedly; read the current page before adding it. - Instagram, which needs a professional account, app review and publicly hosted media, and caps API publishing at a hundred posts per rolling twenty four hours (checked), so a bulk queue is the wrong shape for it. - LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project of their own. - Analytics, approvals and teams, which multiply every integration by a permission model. ### README must contain - How to obtain a Mastodon application token and a Bluesky app password, the documented way - The send state machine, and that needs_reconciliation means the network is asked before anything is resent - That limits are fetched from each network and shown with their fetch time, never typed in - Which networks are excluded and the specific reason for each - That the app binds to localhost and every secret lives in the environment ===== AGENTS.md ===== # Agent instructions · BulkPublish indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One process on an always-on machine with a scheduler tick every minute, SQLite with the send state machine enforced in the schema, Mastodon and Bluesky only, one module each behind one publish function, Fetched from each network at connect time and rechecked daily, never hard-coded, The network's own key where it offers one, the state machine everywhere, Copied beside the database, checked and resized at import, deleted after a stated retention. 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 - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ===== BUILD_PLAN.md ===== # Build plan · BulkPublish A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Importing a spreadsheet that tells you what is wrong Every row is checked against the network's own limits before it is accepted. ### Steps 1. Fetch limits from the network Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py`, `src/limits.py` 2. Count graphemes, not characters The limit is in graphemes and bytes, and an emoji is neither one character nor one byte. Files: `src/validate.py`, `tests/test_graphemes.py` 3. Show every failure beside its row A rejected import with reasons beats an accepted one that fails at midnight. Files: `src/templates/import.html` ### Done when - [ ] Limits are fetched per account with a fetch time - [ ] Emoji captions are counted in graphemes - [ ] Non-default server limits are honoured - [ ] Oversized images are flagged at import ### Watch out - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. ## Phase 2 · Slots in the account's timezone Rows without a time land in the next free posting slot, correctly across a clock change. ### Steps 1. Store instants, display zones Slots come from the zone's rules for that date, not from a fixed offset. Files: `src/slots.py`, `tests/test_dst.py` 2. Fill the next free slot only Two rows in one slot is a double post with extra steps. Files: `src/slots.py` 3. Report how far a batch reaches A hundred rows into two slots a day is seven weeks, and the person should know. Files: `src/templates/import.html` ### Done when - [ ] No two rows share a slot - [ ] Both clock change days resolve by the stated rule - [ ] A large batch reports its reach before import - [ ] Stored instants and displayed times agree ### Watch out - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. ## Phase 3 · Sending exactly once A crash, a timeout or a double tick produces one post, never two. ### Steps 1. Lock the row in its own transaction first Before the network is touched. This is the whole protection. Files: `src/sender.py`, `tests/test_exactly_once.py` 2. Send an idempotency key where the network honours one A one hour memory on the server is a second line of defence, not the first. Files: `src/networks/mastodon.py` 3. Reconcile unknown outcomes by asking Fetch recent posts and match the key before ever resending. Files: `src/reconcile.py` ### Done when - [ ] A kill between accept and record yields one post - [ ] Concurrent ticks produce one send - [ ] Confirmed sends are never retried - [ ] Unknown outcomes are reconciled, not resent ### Watch out - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. ## Phase 4 · Media that arrives whole Every image is accepted by the network the first time. ### Steps 1. Resize to the fetched limit at import Not at send time, when nobody is watching. Files: `src/media.py`, `tests/test_media.py` 2. Wait for media to be ready before posting Some networks process uploads after accepting them; check how each one signals ready. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py` 3. Delete after retention, and log it Media outlives its usefulness by exactly the retention period. Files: `src/retention.py` ### Done when - [ ] Oversized images are resized at import - [ ] Missing alt text is flagged where required - [ ] Posts reference media only once ready - [ ] Media is deleted after retention with a log line ### Watch out - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ## Not in this build - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. ## After v1, if you want it - A third open network added as a module, once the sender has run for a month without a duplicate - Repeat schedules for evergreen rows, which reuse the slot logic and add nothing to the sender - A preview of each post rendered per network, since the same caption reads differently on each ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, attempts and accounts. The attempts table is the audit trail. DATABASE_PATH=./data/scheduler.db # Required. The server you post to. Its limits are fetched from here. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password, so it can be revoked alone. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. Copies of imported media, resized to the fetched limits, deleted after retention. MEDIA_DIR=./data/media # Required. After a row sends. The deletion is logged. MEDIA_RETENTION_DAYS=90 # Required. A row in sending for longer than this becomes needs_reconciliation, never a retry. SENDING_STALE_MINUTES=10
You are building a lean indie version of BulkPublish. 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 ===== # BulkPublish · indie build A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one. 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 | One process on an always-on machine with a scheduler tick every minute | a scheduler that only runs when the laptop is open is a to-do list | | Storage | SQLite with the send state machine enforced in the schema | the duplicate-post bug is a database bug, so the database prevents it | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs with published limits and no review gate | | Limits | Fetched from each network at connect time and rechecked daily, never hard-coded | the limit you typed in is the one that changed last month | | Idempotency | The network's own key where it offers one, the state machine everywhere | belt and braces, because one of them has a one hour memory | | Media | Copied beside the database, checked and resized at import, deleted after a stated retention | a twelve megabyte photograph is a rejected post | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the scheduler and the web interface in one process. - Get it: python.org, or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **A machine that is always on** · a few pounds a month, or free if you own the box - Why: The scheduler tick has to fire at the slot, and a laptop that sleeps turns the queue into a list of things that did not happen. - Get it: A small VPS, a home server or a single-board computer with a static power supply. - Verify: A test row scheduled for three in the morning has sent by breakfast - [ ] **A Mastodon account with an application token** · free - Why: One of the two networks, connected the documented way with a token you can revoke. - Get it: Create an application in your account's development settings with write scope for statuses and media, and copy the access token. - Verify: The instance endpoint returns that server's limits with your token - [ ] **A Bluesky account with an app password** · free - Why: The other network, connected with an app password rather than your main one so it can be revoked alone. - Get it: Create an app password in your account settings and use it with your handle. - Verify: A session is created and your profile resolves - [ ] **A throwaway account on each network** · free - Why: Exactly-once and reconciliation are only testable by actually sending, and a duplicate on your real account is a duplicate your followers see. - Get it: A second account on each network that nobody follows, connected the same way. - Verify: Both test accounts are connected and a row sends to each - [ ] **Your slots, retention and clock change rule** · free - Why: Each is asserted by a test, and deciding them after seeing failures means choosing whatever passes. - Get it: Write down the posting slots per account, how long media is kept after sending, and what happens to a slot that falls in the daylight saving gap. - Verify: All three are in the configuration and named in the tests ## 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: - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. - staying connected · networks expire your auth and someone has to notice - knowing a post is malformed before the network rejects it, not after - an honest publish status when a network accepts the upload and fails minutes later - analytics pulled back per post - approvals, teams and multiple client workspaces - the platform review process that gates posting on some networks entirely If one of those is essential to you, that is the reason to keep paying for BulkPublish, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · BulkPublish 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 spreadsheet-first social scheduler that can never post the same row twice to replace BulkPublish. 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 Two rules, and the second is what makes the first possible. Never post twice. A retry after a timeout, a scheduler tick that ran twice, a crash between the network accepting a post and the database hearing about it: each of these produces a duplicate unless the row is locked before the network is touched. So a row moves to 'sending' in its own transaction before any call, a confirmed send is never retried, and a send whose outcome is unknown is settled by asking the network rather than by trying again. Only networks whose published terms allow what you are doing, with credentials obtained the way they document. Here that is Mastodon and Bluesky: open APIs, published limits, no review gate. No browser automation, no cookie-driven posting, no network whose rules you have not read, because a scheduler that gets an account suspended has cost more than it saved. ### Stack (fixed, do not substitute) - Runtime: One process on an always-on machine with a scheduler tick every minute · a scheduler that only runs when the laptop is open is a to-do list - Storage: SQLite with the send state machine enforced in the schema · the duplicate-post bug is a database bug, so the database prevents it - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs with published limits and no review gate - Limits: Fetched from each network at connect time and rechecked daily, never hard-coded · the limit you typed in is the one that changed last month - Idempotency: The network's own key where it offers one, the state machine everywhere · belt and braces, because one of them has a one hour memory - Media: Copied beside the database, checked and resized at import, deleted after a stated retention · a twelve megabyte photograph is a rejected post ### Data model (create this before Phase 1) - accounts(id, network, handle, credential_ref, limits_json, limits_fetched_at, timezone, state, paused_reason) - batches(id, filename, imported_at, rows_total, rows_accepted, report_json) - posts(id, batch_id, account_id, caption, media_ids_json, send_at, state, attempts, idempotency_key, remote_id, remote_url, sent_at, last_error) · state is queued, sending, sent, failed or needs_reconciliation - media(id, path, mime, bytes, width, height, sha256, alt_text, delete_after) - attempts(id, post_id, started_at, finished_at, outcome, response_excerpt) · every network call, including the ones that timed out - slots(account_id, day_of_week, time_local) · posting times in the account's own timezone - The state column is the lock: a row moves to sending in its own transaction before any network call, so a crash or a double tick cannot produce two posts, and a timed-out call leaves needs_reconciliation rather than a retry. ### Phase 1 · Importing a spreadsheet that tells you what is wrong Build: Fetch each network's limits at connect. Mastodon's instance endpoint reports that server's max_characters, max_media_attachments and image_size_limit; Bluesky's post lexicon caps text at 300 graphemes and 3,000 bytes and its image embed allows four images of up to 2,000,000 bytes each with alt text required (checked against the current lexicon, which has changed before). Parse the CSV, check every row per target network counting graphemes, show each failure beside its row, and import only the rows that pass. Trap: Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. Done when: Limits are fetched per account and shown with their fetch time. A caption over the grapheme limit is flagged at import with the count and the limit, tested with emoji. A row targeting a Mastodon server with a non-default limit is checked against that server's figure. An image over the blob limit is flagged before import rather than at send. Do not build yet: No scheduling, sending, media processing or history. ### Phase 2 · Slots in the account's timezone Build: Configure posting slots per account in that account's timezone. Assign each unscheduled row to the next free slot, store the instant, and show the local time beside it. Handle a slot that falls in a daylight saving gap or on the repeated hour, and a batch larger than a week of slots. Trap: Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. Done when: An unscheduled row takes the next free slot and no two rows share one. A slot on each clock change day resolves by the stated rule, tested with both transition dates. A batch larger than the available slots reports how far into the future it reaches before import. The stored instant and the displayed local time agree across the change. Do not build yet: No sending, media processing or history yet. ### Phase 3 · Sending exactly once Build: Each tick selects due rows one at a time and moves each to sending in its own transaction before any network call. Send a client-generated idempotency key: Mastodon honours an Idempotency-Key header for up to an hour (checked), and Bluesky's record creation should be checked for an equivalent before relying on one. A confirmed send is never retried. A timed-out call leaves the row in needs_reconciliation, and a later pass asks the network whether the post exists before deciding anything. Retry with backoff only failures that happened before the network could have accepted. Trap: Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row in needs_reconciliation that resolves to sent on the next pass, tested with a fake network. Two ticks running concurrently produce one send, tested. A confirmed send is never retried, asserted in the suite. Backoff applies only to pre-acceptance failures. Do not build yet: No media processing or history page yet. ### Phase 4 · Media that arrives whole Build: Copy media beside the database at import, check its type and dimensions, and resize or recompress to the fetched blob limit rather than a guessed one. Require alt text where the network requires it. Upload media first, wait until the network reports it ready where uploads are processed asynchronously, then post referencing it. Delete media after the stated retention once the row has sent. Trap: Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. Done when: An oversized image is resized at import to within the fetched limit and the result is recorded. A row missing alt text for a network that requires it is flagged at import. A post references media only after the network reports it ready, tested with a slow fake. Sent rows have their media deleted after the retention and the deletion is logged. Do not build yet: No history page or credential handling yet. ### Phase 5 · Knowing what actually landed Build: A history page showing each row's state, error, attempts and live URL, with retry offered only for failed rows. Verify a sent post by fetching it back. On the first authentication failure pause the account, stop scheduling its rows and alert once, rather than failing every row at its slot. Export the history. Bind to localhost and keep secrets in the environment. Trap: A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. Done when: The history page shows state, attempts, error and the live URL for every row. A sent post is verified by fetching it back, and a post that has since vanished is marked. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Retry is offered only for failed rows and never for sent or sending ones. Do not build yet: Nothing after this. ### Out of scope (and why) - X, whose API is pay-per-use with no free tier, at 0.015 USD per post and 0.200 USD per post carrying a link at the time of checking, and whose pricing has changed repeatedly; read the current page before adding it. - Instagram, which needs a professional account, app review and publicly hosted media, and caps API publishing at a hundred posts per rolling twenty four hours (checked), so a bulk queue is the wrong shape for it. - LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project of their own. - Analytics, approvals and teams, which multiply every integration by a permission model. ### README must contain - How to obtain a Mastodon application token and a Bluesky app password, the documented way - The send state machine, and that needs_reconciliation means the network is asked before anything is resent - That limits are fetched from each network and shown with their fetch time, never typed in - Which networks are excluded and the specific reason for each - That the app binds to localhost and every secret lives in the environment ===== AGENTS.md ===== # Agent instructions · BulkPublish indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One process on an always-on machine with a scheduler tick every minute, SQLite with the send state machine enforced in the schema, Mastodon and Bluesky only, one module each behind one publish function, Fetched from each network at connect time and rechecked daily, never hard-coded, The network's own key where it offers one, the state machine everywhere, Copied beside the database, checked and resized at import, deleted after a stated retention. 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 - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ===== BUILD_PLAN.md ===== # Build plan · BulkPublish A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Importing a spreadsheet that tells you what is wrong Every row is checked against the network's own limits before it is accepted. ### Steps 1. Fetch limits from the network Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py`, `src/limits.py` 2. Count graphemes, not characters The limit is in graphemes and bytes, and an emoji is neither one character nor one byte. Files: `src/validate.py`, `tests/test_graphemes.py` 3. Show every failure beside its row A rejected import with reasons beats an accepted one that fails at midnight. Files: `src/templates/import.html` ### Done when - [ ] Limits are fetched per account with a fetch time - [ ] Emoji captions are counted in graphemes - [ ] Non-default server limits are honoured - [ ] Oversized images are flagged at import ### Watch out - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. ## Phase 2 · Slots in the account's timezone Rows without a time land in the next free posting slot, correctly across a clock change. ### Steps 1. Store instants, display zones Slots come from the zone's rules for that date, not from a fixed offset. Files: `src/slots.py`, `tests/test_dst.py` 2. Fill the next free slot only Two rows in one slot is a double post with extra steps. Files: `src/slots.py` 3. Report how far a batch reaches A hundred rows into two slots a day is seven weeks, and the person should know. Files: `src/templates/import.html` ### Done when - [ ] No two rows share a slot - [ ] Both clock change days resolve by the stated rule - [ ] A large batch reports its reach before import - [ ] Stored instants and displayed times agree ### Watch out - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. ## Phase 3 · Sending exactly once A crash, a timeout or a double tick produces one post, never two. ### Steps 1. Lock the row in its own transaction first Before the network is touched. This is the whole protection. Files: `src/sender.py`, `tests/test_exactly_once.py` 2. Send an idempotency key where the network honours one A one hour memory on the server is a second line of defence, not the first. Files: `src/networks/mastodon.py` 3. Reconcile unknown outcomes by asking Fetch recent posts and match the key before ever resending. Files: `src/reconcile.py` ### Done when - [ ] A kill between accept and record yields one post - [ ] Concurrent ticks produce one send - [ ] Confirmed sends are never retried - [ ] Unknown outcomes are reconciled, not resent ### Watch out - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. ## Phase 4 · Media that arrives whole Every image is accepted by the network the first time. ### Steps 1. Resize to the fetched limit at import Not at send time, when nobody is watching. Files: `src/media.py`, `tests/test_media.py` 2. Wait for media to be ready before posting Some networks process uploads after accepting them; check how each one signals ready. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py` 3. Delete after retention, and log it Media outlives its usefulness by exactly the retention period. Files: `src/retention.py` ### Done when - [ ] Oversized images are resized at import - [ ] Missing alt text is flagged where required - [ ] Posts reference media only once ready - [ ] Media is deleted after retention with a log line ### Watch out - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ## Not in this build - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. ## After v1, if you want it - A third open network added as a module, once the sender has run for a month without a duplicate - Repeat schedules for evergreen rows, which reuse the slot logic and add nothing to the sender - A preview of each post rendered per network, since the same caption reads differently on each ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, attempts and accounts. The attempts table is the audit trail. DATABASE_PATH=./data/scheduler.db # Required. The server you post to. Its limits are fetched from here. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password, so it can be revoked alone. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. Copies of imported media, resized to the fetched limits, deleted after retention. MEDIA_DIR=./data/media # Required. After a row sends. The deletion is logged. MEDIA_RETENTION_DAYS=90 # Required. A row in sending for longer than this becomes needs_reconciliation, never a retry. SENDING_STALE_MINUTES=10
You are building a production product version of BulkPublish. 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 ===== # BulkPublish · product brief ## Problem The part people picture is a weekend: a list of posts, a timer, one API call each. The part that eats the next six months is everything after the first successful post · staying authenticated to a dozen networks, learning each one's media rules the hard way, publishing that reports success and then quietly fails minutes later, retries that must never post twice, and a review process before some networks will accept a post at all. Build it for two accounts you control and it works. Build it for every network on the marketing page and you have taken a job. ## Product outcome A scheduler that imports a spreadsheet, tells you what is wrong with it before accepting a row, posts each row exactly once, and pauses itself the moment a credential dies instead of failing quietly for a week. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a developer app and API credentials per network (several need review before they will publish anything) - somewhere always on for the schedule to fire - somewhere to keep the posts and the media - patience for the retry and duplicate-post edge cases ## Explicit non-goals for v1 - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. - staying connected · networks expire your auth and someone has to notice - knowing a post is malformed before the network rejects it, not after - an honest publish status when a network accepts the upload and fails minutes later - analytics pulled back per post - approvals, teams and multiple client workspaces - the platform review process that gates posting on some networks entirely ## Success criteria - Limits are confirmed fetched from each network with a fetch time - Emoji captions are confirmed counted in graphemes - A kill between accept and record is confirmed to yield one post - Both clock change days are confirmed to resolve by the stated rule - An auth failure is confirmed to pause the account and alert once ===== BRIEF.md ===== # Build brief · BulkPublish 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 spreadsheet-first social scheduler that can never post the same row twice to replace BulkPublish. 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 Two rules, and the second is what makes the first possible. Never post twice. A retry after a timeout, a scheduler tick that ran twice, a crash between the network accepting a post and the database hearing about it: each of these produces a duplicate unless the row is locked before the network is touched. So a row moves to 'sending' in its own transaction before any call, a confirmed send is never retried, and a send whose outcome is unknown is settled by asking the network rather than by trying again. Only networks whose published terms allow what you are doing, with credentials obtained the way they document. Here that is Mastodon and Bluesky: open APIs, published limits, no review gate. No browser automation, no cookie-driven posting, no network whose rules you have not read, because a scheduler that gets an account suspended has cost more than it saved. ### Stack (fixed, do not substitute) - Runtime: One process on an always-on machine with a scheduler tick every minute · a scheduler that only runs when the laptop is open is a to-do list - Storage: SQLite with the send state machine enforced in the schema · the duplicate-post bug is a database bug, so the database prevents it - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs with published limits and no review gate - Limits: Fetched from each network at connect time and rechecked daily, never hard-coded · the limit you typed in is the one that changed last month - Idempotency: The network's own key where it offers one, the state machine everywhere · belt and braces, because one of them has a one hour memory - Media: Copied beside the database, checked and resized at import, deleted after a stated retention · a twelve megabyte photograph is a rejected post ### Data model (create this before Phase 1) - accounts(id, network, handle, credential_ref, limits_json, limits_fetched_at, timezone, state, paused_reason) - batches(id, filename, imported_at, rows_total, rows_accepted, report_json) - posts(id, batch_id, account_id, caption, media_ids_json, send_at, state, attempts, idempotency_key, remote_id, remote_url, sent_at, last_error) · state is queued, sending, sent, failed or needs_reconciliation - media(id, path, mime, bytes, width, height, sha256, alt_text, delete_after) - attempts(id, post_id, started_at, finished_at, outcome, response_excerpt) · every network call, including the ones that timed out - slots(account_id, day_of_week, time_local) · posting times in the account's own timezone - The state column is the lock: a row moves to sending in its own transaction before any network call, so a crash or a double tick cannot produce two posts, and a timed-out call leaves needs_reconciliation rather than a retry. ### Phase 1 · Importing a spreadsheet that tells you what is wrong Build: Fetch each network's limits at connect. Mastodon's instance endpoint reports that server's max_characters, max_media_attachments and image_size_limit; Bluesky's post lexicon caps text at 300 graphemes and 3,000 bytes and its image embed allows four images of up to 2,000,000 bytes each with alt text required (checked against the current lexicon, which has changed before). Parse the CSV, check every row per target network counting graphemes, show each failure beside its row, and import only the rows that pass. Trap: Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. Done when: Limits are fetched per account and shown with their fetch time. A caption over the grapheme limit is flagged at import with the count and the limit, tested with emoji. A row targeting a Mastodon server with a non-default limit is checked against that server's figure. An image over the blob limit is flagged before import rather than at send. Do not build yet: No scheduling, sending, media processing or history. ### Phase 2 · Slots in the account's timezone Build: Configure posting slots per account in that account's timezone. Assign each unscheduled row to the next free slot, store the instant, and show the local time beside it. Handle a slot that falls in a daylight saving gap or on the repeated hour, and a batch larger than a week of slots. Trap: Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. Done when: An unscheduled row takes the next free slot and no two rows share one. A slot on each clock change day resolves by the stated rule, tested with both transition dates. A batch larger than the available slots reports how far into the future it reaches before import. The stored instant and the displayed local time agree across the change. Do not build yet: No sending, media processing or history yet. ### Phase 3 · Sending exactly once Build: Each tick selects due rows one at a time and moves each to sending in its own transaction before any network call. Send a client-generated idempotency key: Mastodon honours an Idempotency-Key header for up to an hour (checked), and Bluesky's record creation should be checked for an equivalent before relying on one. A confirmed send is never retried. A timed-out call leaves the row in needs_reconciliation, and a later pass asks the network whether the post exists before deciding anything. Retry with backoff only failures that happened before the network could have accepted. Trap: Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row in needs_reconciliation that resolves to sent on the next pass, tested with a fake network. Two ticks running concurrently produce one send, tested. A confirmed send is never retried, asserted in the suite. Backoff applies only to pre-acceptance failures. Do not build yet: No media processing or history page yet. ### Phase 4 · Media that arrives whole Build: Copy media beside the database at import, check its type and dimensions, and resize or recompress to the fetched blob limit rather than a guessed one. Require alt text where the network requires it. Upload media first, wait until the network reports it ready where uploads are processed asynchronously, then post referencing it. Delete media after the stated retention once the row has sent. Trap: Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. Done when: An oversized image is resized at import to within the fetched limit and the result is recorded. A row missing alt text for a network that requires it is flagged at import. A post references media only after the network reports it ready, tested with a slow fake. Sent rows have their media deleted after the retention and the deletion is logged. Do not build yet: No history page or credential handling yet. ### Phase 5 · Knowing what actually landed Build: A history page showing each row's state, error, attempts and live URL, with retry offered only for failed rows. Verify a sent post by fetching it back. On the first authentication failure pause the account, stop scheduling its rows and alert once, rather than failing every row at its slot. Export the history. Bind to localhost and keep secrets in the environment. Trap: A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. Done when: The history page shows state, attempts, error and the live URL for every row. A sent post is verified by fetching it back, and a post that has since vanished is marked. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Retry is offered only for failed rows and never for sent or sending ones. Do not build yet: Nothing after this. ### Out of scope (and why) - X, whose API is pay-per-use with no free tier, at 0.015 USD per post and 0.200 USD per post carrying a link at the time of checking, and whose pricing has changed repeatedly; read the current page before adding it. - Instagram, which needs a professional account, app review and publicly hosted media, and caps API publishing at a hundred posts per rolling twenty four hours (checked), so a bulk queue is the wrong shape for it. - LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project of their own. - Analytics, approvals and teams, which multiply every integration by a permission model. ### README must contain - How to obtain a Mastodon application token and a Bluesky app password, the documented way - The send state machine, and that needs_reconciliation means the network is asked before anything is resent - That limits are fetched from each network and shown with their fetch time, never typed in - Which networks are excluded and the specific reason for each - That the app binds to localhost and every secret lives in the environment ===== ARCHITECTURE.md ===== # Architecture · BulkPublish ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One process on an always-on machine with a scheduler tick every minute | a scheduler that only runs when the laptop is open is a to-do list | | Storage | SQLite with the send state machine enforced in the schema | the duplicate-post bug is a database bug, so the database prevents it | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs with published limits and no review gate | | Limits | Fetched from each network at connect time and rechecked daily, never hard-coded | the limit you typed in is the one that changed last month | | Idempotency | The network's own key where it offers one, the state machine everywhere | belt and braces, because one of them has a one hour memory | | Media | Copied beside the database, checked and resized at import, deleted after a stated retention | a twelve megabyte photograph is a rejected post | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Validation against fetched limits in graphemes. | Any parser, provided limits come from the network and not from a constant. | | Slots | Instants computed from zone rules per date. | Any scheduler, provided both clock change days are tested. | | Sender | The state machine and reconciliation. | Not swappable for retry-on-error, which is how duplicates happen. | | Networks | One module per network behind one function. | Any network whose terms permit it, added as a module without touching the sender. | | History | Attempts, verification and account pausing. | Any presentation, provided retry is never offered for a sent row. | ## 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 · Posts, attempts and accounts. The attempts table is the audit trail. - `MASTODON_BASE_URL` · required · The server you post to. Its limits are fetched from here. - `MASTODON_ACCESS_TOKEN` · required, secret · Write scope for statuses and media only. Revoke it to test the pause. - `BLUESKY_HANDLE` · required · Your handle, used with an app password. - `BLUESKY_APP_PASSWORD` · required, secret · An app password, never your main password, so it can be revoked alone. - `MEDIA_DIR` · required · Copies of imported media, resized to the fetched limits, deleted after retention. - `MEDIA_RETENTION_DAYS` · required · After a row sends. The deletion is logged. - `SENDING_STALE_MINUTES` · required · A row in sending for longer than this becomes needs_reconciliation, never a retry. ## 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 · BulkPublish product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One process on an always-on machine with a scheduler tick every minute, SQLite with the send state machine enforced in the schema, Mastodon and Bluesky only, one module each behind one publish function, Fetched from each network at connect time and rechecked daily, never hard-coded, The network's own key where it offers one, the state machine everywhere, Copied beside the database, checked and resized at import, deleted after a stated retention. - 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 - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. - A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. ===== MILESTONES.md ===== # Delivery milestones · BulkPublish Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Importing a spreadsheet that tells you what is wrong Every row is checked against the network's own limits before it is accepted. ### Steps 1. Fetch limits from the network Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py`, `src/limits.py` 2. Count graphemes, not characters The limit is in graphemes and bytes, and an emoji is neither one character nor one byte. Files: `src/validate.py`, `tests/test_graphemes.py` 3. Show every failure beside its row A rejected import with reasons beats an accepted one that fails at midnight. Files: `src/templates/import.html` ### Done when - [ ] Limits are fetched per account with a fetch time - [ ] Emoji captions are counted in graphemes - [ ] Non-default server limits are honoured - [ ] Oversized images are flagged at import ### Watch out - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. ## M2 · Slots in the account's timezone Rows without a time land in the next free posting slot, correctly across a clock change. ### Steps 1. Store instants, display zones Slots come from the zone's rules for that date, not from a fixed offset. Files: `src/slots.py`, `tests/test_dst.py` 2. Fill the next free slot only Two rows in one slot is a double post with extra steps. Files: `src/slots.py` 3. Report how far a batch reaches A hundred rows into two slots a day is seven weeks, and the person should know. Files: `src/templates/import.html` ### Done when - [ ] No two rows share a slot - [ ] Both clock change days resolve by the stated rule - [ ] A large batch reports its reach before import - [ ] Stored instants and displayed times agree ### Watch out - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. ## M3 · Sending exactly once A crash, a timeout or a double tick produces one post, never two. ### Steps 1. Lock the row in its own transaction first Before the network is touched. This is the whole protection. Files: `src/sender.py`, `tests/test_exactly_once.py` 2. Send an idempotency key where the network honours one A one hour memory on the server is a second line of defence, not the first. Files: `src/networks/mastodon.py` 3. Reconcile unknown outcomes by asking Fetch recent posts and match the key before ever resending. Files: `src/reconcile.py` ### Done when - [ ] A kill between accept and record yields one post - [ ] Concurrent ticks produce one send - [ ] Confirmed sends are never retried - [ ] Unknown outcomes are reconciled, not resent ### Watch out - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. ## M4 · Media that arrives whole Every image is accepted by the network the first time. ### Steps 1. Resize to the fetched limit at import Not at send time, when nobody is watching. Files: `src/media.py`, `tests/test_media.py` 2. Wait for media to be ready before posting Some networks process uploads after accepting them; check how each one signals ready. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py` 3. Delete after retention, and log it Media outlives its usefulness by exactly the retention period. Files: `src/retention.py` ### Done when - [ ] Oversized images are resized at import - [ ] Missing alt text is flagged where required - [ ] Posts reference media only once ready - [ ] Media is deleted after retention with a log line ### Watch out - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ## M5 · Knowing what actually landed (production only) Every row's fate is visible, a dead credential pauses an account, and nothing fails silently at midnight. ### Steps 1. Show every row's fate with its attempts The attempts table is the audit trail when a post is missing. Files: `src/templates/history.html` 2. Pause the account on the first auth failure One alert beats a week of failures. Files: `src/sender.py`, `tests/test_pause.py` 3. Verify by fetching the post back Accepted is not the same as published. Files: `src/verify.py` ### Done when - [ ] Every row shows state, attempts, error and URL - [ ] Sent posts are verified by fetching them back - [ ] An auth failure pauses the account and alerts once - [ ] Retry is offered only for failed rows ### Watch out - A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. ===== OPERATIONS.md ===== # Operations · BulkPublish ## Backup The database and the media folder together, since a restore of one without the other leaves rows referencing images that no longer exist. The attempts table is the audit trail and is worth more than the posts table when something is missing. ## Restore Restore and start with every row that was in sending moved to needs_reconciliation, so nothing is resent on the assumption that it failed. Then run reconciliation before the next tick. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rows in needs_reconciliation, paused accounts, and the age of each account's fetched limits. A paused account nobody read the alert for is the most common way a feed goes quiet. ## Incident checklist A duplicate post means the lock was bypassed, and the attempts table shows the two calls. A gap in the feed means an account paused or a row failed at import, and both are visible on the history page rather than in a log. 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 - [ ] Limits are confirmed fetched from each network with a fetch time - [ ] Emoji captions are confirmed counted in graphemes - [ ] A kill between accept and record is confirmed to yield one post - [ ] Both clock change days are confirmed to resolve by the stated rule - [ ] An auth failure is confirmed to pause the account and alert once ## Launch constraint Do not market omitted BulkPublish 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. Posts, attempts and accounts. The attempts table is the audit trail. DATABASE_PATH=./data/scheduler.db # Required. The server you post to. Its limits are fetched from here. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password, so it can be revoked alone. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. Copies of imported media, resized to the fetched limits, deleted after retention. MEDIA_DIR=./data/media # Required. After a row sends. The deletion is logged. MEDIA_RETENTION_DAYS=90 # Required. A row in sending for longer than this becomes needs_reconciliation, never a retry. SENDING_STALE_MINUTES=10
# BulkPublish · indie build A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one. 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 | One process on an always-on machine with a scheduler tick every minute | a scheduler that only runs when the laptop is open is a to-do list | | Storage | SQLite with the send state machine enforced in the schema | the duplicate-post bug is a database bug, so the database prevents it | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs with published limits and no review gate | | Limits | Fetched from each network at connect time and rechecked daily, never hard-coded | the limit you typed in is the one that changed last month | | Idempotency | The network's own key where it offers one, the state machine everywhere | belt and braces, because one of them has a one hour memory | | Media | Copied beside the database, checked and resized at import, deleted after a stated retention | a twelve megabyte photograph is a rejected post | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the scheduler and the web interface in one process. - Get it: python.org, or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **A machine that is always on** · a few pounds a month, or free if you own the box - Why: The scheduler tick has to fire at the slot, and a laptop that sleeps turns the queue into a list of things that did not happen. - Get it: A small VPS, a home server or a single-board computer with a static power supply. - Verify: A test row scheduled for three in the morning has sent by breakfast - [ ] **A Mastodon account with an application token** · free - Why: One of the two networks, connected the documented way with a token you can revoke. - Get it: Create an application in your account's development settings with write scope for statuses and media, and copy the access token. - Verify: The instance endpoint returns that server's limits with your token - [ ] **A Bluesky account with an app password** · free - Why: The other network, connected with an app password rather than your main one so it can be revoked alone. - Get it: Create an app password in your account settings and use it with your handle. - Verify: A session is created and your profile resolves - [ ] **A throwaway account on each network** · free - Why: Exactly-once and reconciliation are only testable by actually sending, and a duplicate on your real account is a duplicate your followers see. - Get it: A second account on each network that nobody follows, connected the same way. - Verify: Both test accounts are connected and a row sends to each - [ ] **Your slots, retention and clock change rule** · free - Why: Each is asserted by a test, and deciding them after seeing failures means choosing whatever passes. - Get it: Write down the posting slots per account, how long media is kept after sending, and what happens to a slot that falls in the daylight saving gap. - Verify: All three are in the configuration and named in the tests ## 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: - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. - staying connected · networks expire your auth and someone has to notice - knowing a post is malformed before the network rejects it, not after - an honest publish status when a network accepts the upload and fails minutes later - analytics pulled back per post - approvals, teams and multiple client workspaces - the platform review process that gates posting on some networks entirely If one of those is essential to you, that is the reason to keep paying for BulkPublish, and the README should say so rather than pretend.
# Build brief · BulkPublish 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 spreadsheet-first social scheduler that can never post the same row twice to replace BulkPublish. 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 Two rules, and the second is what makes the first possible. Never post twice. A retry after a timeout, a scheduler tick that ran twice, a crash between the network accepting a post and the database hearing about it: each of these produces a duplicate unless the row is locked before the network is touched. So a row moves to 'sending' in its own transaction before any call, a confirmed send is never retried, and a send whose outcome is unknown is settled by asking the network rather than by trying again. Only networks whose published terms allow what you are doing, with credentials obtained the way they document. Here that is Mastodon and Bluesky: open APIs, published limits, no review gate. No browser automation, no cookie-driven posting, no network whose rules you have not read, because a scheduler that gets an account suspended has cost more than it saved. ### Stack (fixed, do not substitute) - Runtime: One process on an always-on machine with a scheduler tick every minute · a scheduler that only runs when the laptop is open is a to-do list - Storage: SQLite with the send state machine enforced in the schema · the duplicate-post bug is a database bug, so the database prevents it - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs with published limits and no review gate - Limits: Fetched from each network at connect time and rechecked daily, never hard-coded · the limit you typed in is the one that changed last month - Idempotency: The network's own key where it offers one, the state machine everywhere · belt and braces, because one of them has a one hour memory - Media: Copied beside the database, checked and resized at import, deleted after a stated retention · a twelve megabyte photograph is a rejected post ### Data model (create this before Phase 1) - accounts(id, network, handle, credential_ref, limits_json, limits_fetched_at, timezone, state, paused_reason) - batches(id, filename, imported_at, rows_total, rows_accepted, report_json) - posts(id, batch_id, account_id, caption, media_ids_json, send_at, state, attempts, idempotency_key, remote_id, remote_url, sent_at, last_error) · state is queued, sending, sent, failed or needs_reconciliation - media(id, path, mime, bytes, width, height, sha256, alt_text, delete_after) - attempts(id, post_id, started_at, finished_at, outcome, response_excerpt) · every network call, including the ones that timed out - slots(account_id, day_of_week, time_local) · posting times in the account's own timezone - The state column is the lock: a row moves to sending in its own transaction before any network call, so a crash or a double tick cannot produce two posts, and a timed-out call leaves needs_reconciliation rather than a retry. ### Phase 1 · Importing a spreadsheet that tells you what is wrong Build: Fetch each network's limits at connect. Mastodon's instance endpoint reports that server's max_characters, max_media_attachments and image_size_limit; Bluesky's post lexicon caps text at 300 graphemes and 3,000 bytes and its image embed allows four images of up to 2,000,000 bytes each with alt text required (checked against the current lexicon, which has changed before). Parse the CSV, check every row per target network counting graphemes, show each failure beside its row, and import only the rows that pass. Trap: Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. Done when: Limits are fetched per account and shown with their fetch time. A caption over the grapheme limit is flagged at import with the count and the limit, tested with emoji. A row targeting a Mastodon server with a non-default limit is checked against that server's figure. An image over the blob limit is flagged before import rather than at send. Do not build yet: No scheduling, sending, media processing or history. ### Phase 2 · Slots in the account's timezone Build: Configure posting slots per account in that account's timezone. Assign each unscheduled row to the next free slot, store the instant, and show the local time beside it. Handle a slot that falls in a daylight saving gap or on the repeated hour, and a batch larger than a week of slots. Trap: Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. Done when: An unscheduled row takes the next free slot and no two rows share one. A slot on each clock change day resolves by the stated rule, tested with both transition dates. A batch larger than the available slots reports how far into the future it reaches before import. The stored instant and the displayed local time agree across the change. Do not build yet: No sending, media processing or history yet. ### Phase 3 · Sending exactly once Build: Each tick selects due rows one at a time and moves each to sending in its own transaction before any network call. Send a client-generated idempotency key: Mastodon honours an Idempotency-Key header for up to an hour (checked), and Bluesky's record creation should be checked for an equivalent before relying on one. A confirmed send is never retried. A timed-out call leaves the row in needs_reconciliation, and a later pass asks the network whether the post exists before deciding anything. Retry with backoff only failures that happened before the network could have accepted. Trap: Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row in needs_reconciliation that resolves to sent on the next pass, tested with a fake network. Two ticks running concurrently produce one send, tested. A confirmed send is never retried, asserted in the suite. Backoff applies only to pre-acceptance failures. Do not build yet: No media processing or history page yet. ### Phase 4 · Media that arrives whole Build: Copy media beside the database at import, check its type and dimensions, and resize or recompress to the fetched blob limit rather than a guessed one. Require alt text where the network requires it. Upload media first, wait until the network reports it ready where uploads are processed asynchronously, then post referencing it. Delete media after the stated retention once the row has sent. Trap: Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. Done when: An oversized image is resized at import to within the fetched limit and the result is recorded. A row missing alt text for a network that requires it is flagged at import. A post references media only after the network reports it ready, tested with a slow fake. Sent rows have their media deleted after the retention and the deletion is logged. Do not build yet: No history page or credential handling yet. ### Phase 5 · Knowing what actually landed Build: A history page showing each row's state, error, attempts and live URL, with retry offered only for failed rows. Verify a sent post by fetching it back. On the first authentication failure pause the account, stop scheduling its rows and alert once, rather than failing every row at its slot. Export the history. Bind to localhost and keep secrets in the environment. Trap: A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. Done when: The history page shows state, attempts, error and the live URL for every row. A sent post is verified by fetching it back, and a post that has since vanished is marked. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Retry is offered only for failed rows and never for sent or sending ones. Do not build yet: Nothing after this. ### Out of scope (and why) - X, whose API is pay-per-use with no free tier, at 0.015 USD per post and 0.200 USD per post carrying a link at the time of checking, and whose pricing has changed repeatedly; read the current page before adding it. - Instagram, which needs a professional account, app review and publicly hosted media, and caps API publishing at a hundred posts per rolling twenty four hours (checked), so a bulk queue is the wrong shape for it. - LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project of their own. - Analytics, approvals and teams, which multiply every integration by a permission model. ### README must contain - How to obtain a Mastodon application token and a Bluesky app password, the documented way - The send state machine, and that needs_reconciliation means the network is asked before anything is resent - That limits are fetched from each network and shown with their fetch time, never typed in - Which networks are excluded and the specific reason for each - That the app binds to localhost and every secret lives in the environment
# Agent instructions · BulkPublish indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One process on an always-on machine with a scheduler tick every minute, SQLite with the send state machine enforced in the schema, Mastodon and Bluesky only, one module each behind one publish function, Fetched from each network at connect time and rechecked daily, never hard-coded, The network's own key where it offers one, the state machine everywhere, Copied beside the database, checked and resized at import, deleted after a stated retention. 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 - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then.
# Build plan · BulkPublish A spreadsheet-first scheduler for two open networks, whose hard problems are validating rows against limits the networks themselves publish, sending each row exactly once through crashes and retries, and noticing an expired credential before a week of posts fails one by one. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Importing a spreadsheet that tells you what is wrong Every row is checked against the network's own limits before it is accepted. ### Steps 1. Fetch limits from the network Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py`, `src/limits.py` 2. Count graphemes, not characters The limit is in graphemes and bytes, and an emoji is neither one character nor one byte. Files: `src/validate.py`, `tests/test_graphemes.py` 3. Show every failure beside its row A rejected import with reasons beats an accepted one that fails at midnight. Files: `src/templates/import.html` ### Done when - [ ] Limits are fetched per account with a fetch time - [ ] Emoji captions are counted in graphemes - [ ] Non-default server limits are honoured - [ ] Oversized images are flagged at import ### Watch out - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. ## Phase 2 · Slots in the account's timezone Rows without a time land in the next free posting slot, correctly across a clock change. ### Steps 1. Store instants, display zones Slots come from the zone's rules for that date, not from a fixed offset. Files: `src/slots.py`, `tests/test_dst.py` 2. Fill the next free slot only Two rows in one slot is a double post with extra steps. Files: `src/slots.py` 3. Report how far a batch reaches A hundred rows into two slots a day is seven weeks, and the person should know. Files: `src/templates/import.html` ### Done when - [ ] No two rows share a slot - [ ] Both clock change days resolve by the stated rule - [ ] A large batch reports its reach before import - [ ] Stored instants and displayed times agree ### Watch out - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. ## Phase 3 · Sending exactly once A crash, a timeout or a double tick produces one post, never two. ### Steps 1. Lock the row in its own transaction first Before the network is touched. This is the whole protection. Files: `src/sender.py`, `tests/test_exactly_once.py` 2. Send an idempotency key where the network honours one A one hour memory on the server is a second line of defence, not the first. Files: `src/networks/mastodon.py` 3. Reconcile unknown outcomes by asking Fetch recent posts and match the key before ever resending. Files: `src/reconcile.py` ### Done when - [ ] A kill between accept and record yields one post - [ ] Concurrent ticks produce one send - [ ] Confirmed sends are never retried - [ ] Unknown outcomes are reconciled, not resent ### Watch out - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. ## Phase 4 · Media that arrives whole Every image is accepted by the network the first time. ### Steps 1. Resize to the fetched limit at import Not at send time, when nobody is watching. Files: `src/media.py`, `tests/test_media.py` 2. Wait for media to be ready before posting Some networks process uploads after accepting them; check how each one signals ready. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py` 3. Delete after retention, and log it Media outlives its usefulness by exactly the retention period. Files: `src/retention.py` ### Done when - [ ] Oversized images are resized at import - [ ] Missing alt text is flagged where required - [ ] Posts reference media only once ready - [ ] Media is deleted after retention with a log line ### Watch out - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ## Not in this build - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. ## After v1, if you want it - A third open network added as a module, once the sender has run for a month without a duplicate - Repeat schedules for evergreen rows, which reuse the slot logic and add nothing to the sender - A preview of each post rendered per network, since the same caption reads differently on each
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Posts, attempts and accounts. The attempts table is the audit trail. DATABASE_PATH=./data/scheduler.db # Required. The server you post to. Its limits are fetched from here. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password, so it can be revoked alone. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. Copies of imported media, resized to the fetched limits, deleted after retention. MEDIA_DIR=./data/media # Required. After a row sends. The deletion is logged. MEDIA_RETENTION_DAYS=90 # Required. A row in sending for longer than this becomes needs_reconciliation, never a retry. SENDING_STALE_MINUTES=10
# BulkPublish · product brief ## Problem The part people picture is a weekend: a list of posts, a timer, one API call each. The part that eats the next six months is everything after the first successful post · staying authenticated to a dozen networks, learning each one's media rules the hard way, publishing that reports success and then quietly fails minutes later, retries that must never post twice, and a review process before some networks will accept a post at all. Build it for two accounts you control and it works. Build it for every network on the marketing page and you have taken a job. ## Product outcome A scheduler that imports a spreadsheet, tells you what is wrong with it before accepting a row, posts each row exactly once, and pauses itself the moment a credential dies instead of failing quietly for a week. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a developer app and API credentials per network (several need review before they will publish anything) - somewhere always on for the schedule to fire - somewhere to keep the posts and the media - patience for the retry and duplicate-post edge cases ## Explicit non-goals for v1 - X, Instagram, LinkedIn and TikTok. - Analytics pulled back per post. - Approvals, teams and client workspaces. - Browser automation of any kind. - staying connected · networks expire your auth and someone has to notice - knowing a post is malformed before the network rejects it, not after - an honest publish status when a network accepts the upload and fails minutes later - analytics pulled back per post - approvals, teams and multiple client workspaces - the platform review process that gates posting on some networks entirely ## Success criteria - Limits are confirmed fetched from each network with a fetch time - Emoji captions are confirmed counted in graphemes - A kill between accept and record is confirmed to yield one post - Both clock change days are confirmed to resolve by the stated rule - An auth failure is confirmed to pause the account and alert once
# Build brief · BulkPublish 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 spreadsheet-first social scheduler that can never post the same row twice to replace BulkPublish. 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 Two rules, and the second is what makes the first possible. Never post twice. A retry after a timeout, a scheduler tick that ran twice, a crash between the network accepting a post and the database hearing about it: each of these produces a duplicate unless the row is locked before the network is touched. So a row moves to 'sending' in its own transaction before any call, a confirmed send is never retried, and a send whose outcome is unknown is settled by asking the network rather than by trying again. Only networks whose published terms allow what you are doing, with credentials obtained the way they document. Here that is Mastodon and Bluesky: open APIs, published limits, no review gate. No browser automation, no cookie-driven posting, no network whose rules you have not read, because a scheduler that gets an account suspended has cost more than it saved. ### Stack (fixed, do not substitute) - Runtime: One process on an always-on machine with a scheduler tick every minute · a scheduler that only runs when the laptop is open is a to-do list - Storage: SQLite with the send state machine enforced in the schema · the duplicate-post bug is a database bug, so the database prevents it - Networks: Mastodon and Bluesky only, one module each behind one publish function · open APIs with published limits and no review gate - Limits: Fetched from each network at connect time and rechecked daily, never hard-coded · the limit you typed in is the one that changed last month - Idempotency: The network's own key where it offers one, the state machine everywhere · belt and braces, because one of them has a one hour memory - Media: Copied beside the database, checked and resized at import, deleted after a stated retention · a twelve megabyte photograph is a rejected post ### Data model (create this before Phase 1) - accounts(id, network, handle, credential_ref, limits_json, limits_fetched_at, timezone, state, paused_reason) - batches(id, filename, imported_at, rows_total, rows_accepted, report_json) - posts(id, batch_id, account_id, caption, media_ids_json, send_at, state, attempts, idempotency_key, remote_id, remote_url, sent_at, last_error) · state is queued, sending, sent, failed or needs_reconciliation - media(id, path, mime, bytes, width, height, sha256, alt_text, delete_after) - attempts(id, post_id, started_at, finished_at, outcome, response_excerpt) · every network call, including the ones that timed out - slots(account_id, day_of_week, time_local) · posting times in the account's own timezone - The state column is the lock: a row moves to sending in its own transaction before any network call, so a crash or a double tick cannot produce two posts, and a timed-out call leaves needs_reconciliation rather than a retry. ### Phase 1 · Importing a spreadsheet that tells you what is wrong Build: Fetch each network's limits at connect. Mastodon's instance endpoint reports that server's max_characters, max_media_attachments and image_size_limit; Bluesky's post lexicon caps text at 300 graphemes and 3,000 bytes and its image embed allows four images of up to 2,000,000 bytes each with alt text required (checked against the current lexicon, which has changed before). Parse the CSV, check every row per target network counting graphemes, show each failure beside its row, and import only the rows that pass. Trap: Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. Done when: Limits are fetched per account and shown with their fetch time. A caption over the grapheme limit is flagged at import with the count and the limit, tested with emoji. A row targeting a Mastodon server with a non-default limit is checked against that server's figure. An image over the blob limit is flagged before import rather than at send. Do not build yet: No scheduling, sending, media processing or history. ### Phase 2 · Slots in the account's timezone Build: Configure posting slots per account in that account's timezone. Assign each unscheduled row to the next free slot, store the instant, and show the local time beside it. Handle a slot that falls in a daylight saving gap or on the repeated hour, and a batch larger than a week of slots. Trap: Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. Done when: An unscheduled row takes the next free slot and no two rows share one. A slot on each clock change day resolves by the stated rule, tested with both transition dates. A batch larger than the available slots reports how far into the future it reaches before import. The stored instant and the displayed local time agree across the change. Do not build yet: No sending, media processing or history yet. ### Phase 3 · Sending exactly once Build: Each tick selects due rows one at a time and moves each to sending in its own transaction before any network call. Send a client-generated idempotency key: Mastodon honours an Idempotency-Key header for up to an hour (checked), and Bluesky's record creation should be checked for an equivalent before relying on one. A confirmed send is never retried. A timed-out call leaves the row in needs_reconciliation, and a later pass asks the network whether the post exists before deciding anything. Retry with backoff only failures that happened before the network could have accepted. Trap: Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. Done when: Killing the process between the network accepting and the database recording leaves one live post and a row in needs_reconciliation that resolves to sent on the next pass, tested with a fake network. Two ticks running concurrently produce one send, tested. A confirmed send is never retried, asserted in the suite. Backoff applies only to pre-acceptance failures. Do not build yet: No media processing or history page yet. ### Phase 4 · Media that arrives whole Build: Copy media beside the database at import, check its type and dimensions, and resize or recompress to the fetched blob limit rather than a guessed one. Require alt text where the network requires it. Upload media first, wait until the network reports it ready where uploads are processed asynchronously, then post referencing it. Delete media after the stated retention once the row has sent. Trap: Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. Done when: An oversized image is resized at import to within the fetched limit and the result is recorded. A row missing alt text for a network that requires it is flagged at import. A post references media only after the network reports it ready, tested with a slow fake. Sent rows have their media deleted after the retention and the deletion is logged. Do not build yet: No history page or credential handling yet. ### Phase 5 · Knowing what actually landed Build: A history page showing each row's state, error, attempts and live URL, with retry offered only for failed rows. Verify a sent post by fetching it back. On the first authentication failure pause the account, stop scheduling its rows and alert once, rather than failing every row at its slot. Export the history. Bind to localhost and keep secrets in the environment. Trap: A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced. Done when: The history page shows state, attempts, error and the live URL for every row. A sent post is verified by fetching it back, and a post that has since vanished is marked. An authentication failure pauses the account within one tick and alerts once, tested by revoking a token. Retry is offered only for failed rows and never for sent or sending ones. Do not build yet: Nothing after this. ### Out of scope (and why) - X, whose API is pay-per-use with no free tier, at 0.015 USD per post and 0.200 USD per post carrying a link at the time of checking, and whose pricing has changed repeatedly; read the current page before adding it. - Instagram, which needs a professional account, app review and publicly hosted media, and caps API publishing at a hundred posts per rolling twenty four hours (checked), so a bulk queue is the wrong shape for it. - LinkedIn and TikTok, whose publishing APIs sit behind review processes that are a project of their own. - Analytics, approvals and teams, which multiply every integration by a permission model. ### README must contain - How to obtain a Mastodon application token and a Bluesky app password, the documented way - The send state machine, and that needs_reconciliation means the network is asked before anything is resent - That limits are fetched from each network and shown with their fetch time, never typed in - Which networks are excluded and the specific reason for each - That the app binds to localhost and every secret lives in the environment
# Architecture · BulkPublish ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One process on an always-on machine with a scheduler tick every minute | a scheduler that only runs when the laptop is open is a to-do list | | Storage | SQLite with the send state machine enforced in the schema | the duplicate-post bug is a database bug, so the database prevents it | | Networks | Mastodon and Bluesky only, one module each behind one publish function | open APIs with published limits and no review gate | | Limits | Fetched from each network at connect time and rechecked daily, never hard-coded | the limit you typed in is the one that changed last month | | Idempotency | The network's own key where it offers one, the state machine everywhere | belt and braces, because one of them has a one hour memory | | Media | Copied beside the database, checked and resized at import, deleted after a stated retention | a twelve megabyte photograph is a rejected post | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Validation against fetched limits in graphemes. | Any parser, provided limits come from the network and not from a constant. | | Slots | Instants computed from zone rules per date. | Any scheduler, provided both clock change days are tested. | | Sender | The state machine and reconciliation. | Not swappable for retry-on-error, which is how duplicates happen. | | Networks | One module per network behind one function. | Any network whose terms permit it, added as a module without touching the sender. | | History | Attempts, verification and account pausing. | Any presentation, provided retry is never offered for a sent row. | ## 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 · Posts, attempts and accounts. The attempts table is the audit trail. - `MASTODON_BASE_URL` · required · The server you post to. Its limits are fetched from here. - `MASTODON_ACCESS_TOKEN` · required, secret · Write scope for statuses and media only. Revoke it to test the pause. - `BLUESKY_HANDLE` · required · Your handle, used with an app password. - `BLUESKY_APP_PASSWORD` · required, secret · An app password, never your main password, so it can be revoked alone. - `MEDIA_DIR` · required · Copies of imported media, resized to the fetched limits, deleted after retention. - `MEDIA_RETENTION_DAYS` · required · After a row sends. The deletion is logged. - `SENDING_STALE_MINUTES` · required · A row in sending for longer than this becomes needs_reconciliation, never a retry. ## 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 · BulkPublish product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One process on an always-on machine with a scheduler tick every minute, SQLite with the send state machine enforced in the schema, Mastodon and Bluesky only, one module each behind one publish function, Fetched from each network at connect time and rechecked daily, never hard-coded, The network's own key where it offers one, the state machine everywhere, Copied beside the database, checked and resized at import, deleted after a stated retention. - 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 - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. - A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced.
# Delivery milestones · BulkPublish Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Importing a spreadsheet that tells you what is wrong Every row is checked against the network's own limits before it is accepted. ### Steps 1. Fetch limits from the network Mastodon's instance endpoint and Bluesky's lexicon, stored with a fetch time. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py`, `src/limits.py` 2. Count graphemes, not characters The limit is in graphemes and bytes, and an emoji is neither one character nor one byte. Files: `src/validate.py`, `tests/test_graphemes.py` 3. Show every failure beside its row A rejected import with reasons beats an accepted one that fails at midnight. Files: `src/templates/import.html` ### Done when - [ ] Limits are fetched per account with a fetch time - [ ] Emoji captions are counted in graphemes - [ ] Non-default server limits are honoured - [ ] Oversized images are flagged at import ### Watch out - Counting characters with string length and hard-coding a five hundred character limit passes an emoji-heavy caption that Bluesky rejects on graphemes, and rejects a longer one on a Mastodon server whose administrator raised the limit. Fetch the limits, count graphemes, and treat any limit you typed in as a bug. ## M2 · Slots in the account's timezone Rows without a time land in the next free posting slot, correctly across a clock change. ### Steps 1. Store instants, display zones Slots come from the zone's rules for that date, not from a fixed offset. Files: `src/slots.py`, `tests/test_dst.py` 2. Fill the next free slot only Two rows in one slot is a double post with extra steps. Files: `src/slots.py` 3. Report how far a batch reaches A hundred rows into two slots a day is seven weeks, and the person should know. Files: `src/templates/import.html` ### Done when - [ ] No two rows share a slot - [ ] Both clock change days resolve by the stated rule - [ ] A large batch reports its reach before import - [ ] Stored instants and displayed times agree ### Watch out - Computing slots in naive local time posts an hour early for a week after the clocks change, or fires twice on the repeated hour and never on the missing one. Store instants, compute each slot from the zone's rules for that date, and test the two transition days explicitly. ## M3 · Sending exactly once A crash, a timeout or a double tick produces one post, never two. ### Steps 1. Lock the row in its own transaction first Before the network is touched. This is the whole protection. Files: `src/sender.py`, `tests/test_exactly_once.py` 2. Send an idempotency key where the network honours one A one hour memory on the server is a second line of defence, not the first. Files: `src/networks/mastodon.py` 3. Reconcile unknown outcomes by asking Fetch recent posts and match the key before ever resending. Files: `src/reconcile.py` ### Done when - [ ] A kill between accept and record yields one post - [ ] Concurrent ticks produce one send - [ ] Confirmed sends are never retried - [ ] Unknown outcomes are reconciled, not resent ### Watch out - Retrying on a timeout because the request 'failed' posts twice whenever the network accepted the first attempt and the response was lost, which is the most common failure on a busy evening. Move the row to sending first, and treat an unknown outcome as a question for the network rather than a reason to send again. ## M4 · Media that arrives whole Every image is accepted by the network the first time. ### Steps 1. Resize to the fetched limit at import Not at send time, when nobody is watching. Files: `src/media.py`, `tests/test_media.py` 2. Wait for media to be ready before posting Some networks process uploads after accepting them; check how each one signals ready. Files: `src/networks/mastodon.py`, `src/networks/bluesky.py` 3. Delete after retention, and log it Media outlives its usefulness by exactly the retention period. Files: `src/retention.py` ### Done when - [ ] Oversized images are resized at import - [ ] Missing alt text is flagged where required - [ ] Posts reference media only once ready - [ ] Media is deleted after retention with a log line ### Watch out - Uploading the twelve megabyte original from a phone exceeds the blob limit on one network and is silently downscaled on the other, and the row fails at its slot when nobody is watching. Resize at import against the fetched limit, and flag the row then. ## M5 · Knowing what actually landed (production only) Every row's fate is visible, a dead credential pauses an account, and nothing fails silently at midnight. ### Steps 1. Show every row's fate with its attempts The attempts table is the audit trail when a post is missing. Files: `src/templates/history.html` 2. Pause the account on the first auth failure One alert beats a week of failures. Files: `src/sender.py`, `tests/test_pause.py` 3. Verify by fetching the post back Accepted is not the same as published. Files: `src/verify.py` ### Done when - [ ] Every row shows state, attempts, error and URL - [ ] Sent posts are verified by fetching them back - [ ] An auth failure pauses the account and alerts once - [ ] Retry is offered only for failed rows ### Watch out - A token expires overnight and every row fails one by one at its slot, so the failure is discovered a week later as a gap in the feed. Pause the account on the first authentication failure and send one alert, then resume when the credential is replaced.
# Operations · BulkPublish ## Backup The database and the media folder together, since a restore of one without the other leaves rows referencing images that no longer exist. The attempts table is the audit trail and is worth more than the posts table when something is missing. ## Restore Restore and start with every row that was in sending moved to needs_reconciliation, so nothing is resent on the assumption that it failed. Then run reconciliation before the next tick. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rows in needs_reconciliation, paused accounts, and the age of each account's fetched limits. A paused account nobody read the alert for is the most common way a feed goes quiet. ## Incident checklist A duplicate post means the lock was bypassed, and the attempts table shows the two calls. A gap in the feed means an account paused or a row failed at import, and both are visible on the history page rather than in a log. 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 - [ ] Limits are confirmed fetched from each network with a fetch time - [ ] Emoji captions are confirmed counted in graphemes - [ ] A kill between accept and record is confirmed to yield one post - [ ] Both clock change days are confirmed to resolve by the stated rule - [ ] An auth failure is confirmed to pause the account and alert once ## Launch constraint Do not market omitted BulkPublish 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. Posts, attempts and accounts. The attempts table is the audit trail. DATABASE_PATH=./data/scheduler.db # Required. The server you post to. Its limits are fetched from here. MASTODON_BASE_URL=https://mastodon.example # Required · secret. Write scope for statuses and media only. Revoke it to test the pause. MASTODON_ACCESS_TOKEN=a token from your application settings # Required. Your handle, used with an app password. BLUESKY_HANDLE=you.bsky.social # Required · secret. An app password, never your main password, so it can be revoked alone. BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # Required. Copies of imported media, resized to the fetched limits, deleted after retention. MEDIA_DIR=./data/media # Required. After a row sends. The deletion is logged. MEDIA_RETENTION_DAYS=90 # Required. A row in sending for longer than this becomes needs_reconciliation, never a retry. SENDING_STALE_MINUTES=10
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because a scheduler is only worth anything when it is boring, and a self-built one stops being boring the first time a token expires overnight or a network changes its video rules without telling anyone. People pay so that maintenance is somebody else's Tuesday.
xstaying connected · networks expire your auth and someone has to notice
xknowing a post is malformed before the network rejects it, not after
xan honest publish status when a network accepts the upload and fails minutes later
xanalytics pulled back per post
xapprovals, teams and multiple client workspaces
xthe platform review process that gates posting on some networks entirely
Don't feel like building it? These folks already made it free.
all 6 free alternatives to BulkPublish →· no votes, no pay-to-list · just what's real
BulkPublish pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 3 channels (1/platform); 3 posts/day and 30/month; 10 scheduled posts; 100 MB media; 30 API requests/day; 1 organization; 50 AI captions/month plus credits; 1 RSS draft feed; no repeat posts. |
| pro | $13.99/workspace | $11.89/workspace | 2 channels/platform; 30 posts/day (900/month); 30 scheduled/day; 2 GB media; 5 API keys; 5,000 API requests/day; 3 organizations; 10 repeat posts; 300 AI captions/month; 10 RSS auto-publish feeds. |
| business | $39.99/workspace | $33.99/workspace | 5 channels/platform; unlimited posts; 100 scheduled/day; 10 GB media; 10 API keys; 50,000 API requests/day; 10 organizations; unlimited repeats; 1,000 AI captions/month; 50 RSS auto-publish feeds. |
free tier3 channels (1/platform); 3 posts/day and 30/month; 10 scheduled posts; 100 MB media; 30 API requests/day; 1 organization; 50 AI captions/month plus credits; 1 RSS draft feed; no repeat posts
billingmonthly + annual (15% lower on annual billing)
hidden costsPublishing to X uses separate pay-as-you-go credits, and additional AI credits can add cost; current public unit prices were not exposed.
verified 2026-08-14 · source ↗
Is BulkPublish free?
The free plan covers three channels, thirty posts a month, ten scheduled at a time and 100 MB of media, with repeat posts and RSS auto-publishing held back for paid plans. Paid is Pro at $13.99/mo (checked 2026-08-08).
Vibecode BulkPublish
Kinda. The core of BulkPublish is buildable in a weekend with the prompt on this page, but there are real gaps: staying connected · networks expire your auth and someone has to notice, knowing a post is malformed before the network rejects it, not after. Read the honest list above before committing.
How much does BulkPublish cost?
BulkPublish costs about $13.99/month (Pro, checked 2026-08-08), which is $167.88 per year.
What do I lose by replacing BulkPublish?
Honestly: staying connected · networks expire your auth and someone has to notice; knowing a post is malformed before the network rejects it, not after; an honest publish status when a network accepts the upload and fails minutes later; analytics pulled back per post; approvals, teams and multiple client workspaces; the platform review process that gates posting on some networks entirely. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to BulkPublish?
Yes: Postiz (The broadest open scheduler here; free means running PostgreSQL, Redis, Temporal and your own platform credentials.) Mixpost Lite (A polished single-user scheduler with queues and basic analytics; the team features live in the paid edition.) Publer Free (Three non-X accounts and ten queued posts each, with a bulk CSV importer that is the closest free match for the actual job.) All 6 curated free alternatives are at vibecodeit.com/bulkpublish/alternatives. The prompt is for when you want it exactly your way.