Vibecode Predis.ai
track this build4 phases, 12 steps, beginner friendly0%The visible AI social content loop is buildable, but a credible replacement needs more than the first screen. Predis.ai earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Predis.ai. 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 ===== # Predis.ai · indie build A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation. 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 | Next.js for the interface and a separate Node worker process, both on Postgres | publishing is a job with retries, not a request somebody waits on | | Queue | One scheduled row per target with a unique idempotency key and an atomic claim | double posting is the failure everyone in this category ships at least once | | Publishing | Official platform APIs only, against accounts you own, with your own credentials | there is no honest build that automates a network which has not published an API for it | | Drafting | One model call per platform variant, stored as a draft a person approves | unreviewed generated copy going out under your own name is how accounts get restricted | | Media | Local files validated against each platform's published rules before any upload | a container rejected after upload has already spent an attempt | | Export | A dated folder and CSV for every network without a publishing API | the honest answer there is a reminder and a clipboard | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs both the interface and the worker. - Get it: nodejs.org or a version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: The queue relies on a conditional update returning the claimed row and on a unique index, both of which you want on a real database rather than a file. - Get it: Docker, or your package manager. - Verify: A claim query with RETURNING runs and the unique index rejects a duplicate - [ ] **A platform account of the type the publishing API requires** · free, though app review may apply - Why: Most networks publish only from a business or creator account through an app you have registered, and finding that out after building the queue costs a weekend. - Get it: Convert the account in its own settings and register a developer application. - Verify: A test call publishes to the account you intended and nowhere else - [ ] **The current published limits for every platform you target** · free - Why: Caption lengths, media counts, aspect ratios and publishing quotas all change, and this build stores each one with the date you read it rather than pretending it is permanent. - Get it: Read each platform's own developer documentation and fill platform_rules. - Verify: Every row has a source URL and a checked_on date - [ ] **Which targets are api and which are export** · free - Why: Deciding this first stops the build drifting towards an unofficial integration for the one network that will not cooperate. - Get it: List your accounts and mark each publishes_via value before writing the worker. - Verify: Every target row has a value and the README lists the export-only ones ## 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: - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. - protection from API and policy changes - production codecs, rendering speed, and media templates - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows If one of those is essential to you, that is the reason to keep paying for Predis.ai, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Predis.ai 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 personal social content planner that drafts per-platform variants, holds a queue that cannot double-post, and publishes only where an official API allows it to replace Predis.ai. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js for the interface and a separate Node worker process, both on Postgres · publishing is a job with retries, not a request somebody waits on - Queue: One scheduled row per target with a unique idempotency key and an atomic claim · double posting is the failure everyone in this category ships at least once - Publishing: Official platform APIs only, against accounts you own, with your own credentials · there is no honest build that automates a network which has not published an API for it - Drafting: One model call per platform variant, stored as a draft a person approves · unreviewed generated copy going out under your own name is how accounts get restricted - Media: Local files validated against each platform's published rules before any upload · a container rejected after upload has already spent an attempt - Export: A dated folder and CSV for every network without a publishing API · the honest answer there is a reminder and a clipboard ### Data model (create this before Phase 1) - posts(id, created_at, topic, body_master, state) · one idea, before it becomes platform-specific - variants(id, post_id, platform, body, media_json, char_count, validated_at, validation_errors_json) - targets(id, platform, account_handle, credential_ref, publishes_via, added_at) · publishes_via is api or export, and there is no third value - schedule(id, variant_id, target_id, run_at, claimed_at, claimed_by, attempts, idempotency_key, remote_id, state, last_error) - containers(id, schedule_id, platform_container_id, status_code, poll_count, first_polled_at, last_polled_at) · the asynchronous upload state for the platforms that work that way - quotas(platform, account_handle, window_start, used, limit_value, checked_at) · read from the platform's own quota endpoint, never assumed from documentation you read once - platform_rules(platform, field, value, source_url, checked_on) · every character limit, aspect ratio and file type in one table with the date you last confirmed it - There is one unique index on (target_id, idempotency_key). It is the only thing standing between a worker restart and a duplicate post. ### Phase 1 · One idea, several variants, real limits Build: Store one master body and generate a variant per target platform, either by hand or with one model call. Validate every variant against the platform_rules table: length, media count, aspect ratio, file type. Show the failures inline. Nothing leaves this screen unvalidated. Trap: Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. Done when: Every limit used anywhere in the application comes from platform_rules and carries a source URL and a checked_on date, asserted by a test that fails on a hard-coded number. A variant that violates a limit cannot be scheduled and the reason names the rule and its source. A caption containing emoji and a link counts the way its platform documents, tested against the published rule. Media is validated before upload, not after, tested with a deliberately wrong aspect ratio. Do not build yet: No queue, no publishing, no credentials and no export yet. ### Phase 2 · A queue that cannot post twice Build: Claim due rows with a single conditional update returning the claimed row. Derive an idempotency key from the variant, the target and the scheduled time, and put a unique index on it. Record the remote id in the same transaction that marks the row done. Trap: A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. Done when: Killing the worker immediately after the API call and restarting it produces exactly one post, tested against a stub that records calls. Two workers started together never claim the same row, tested with a loop. The unique index on (target_id, idempotency_key) exists and a duplicate insert is rejected rather than handled quietly. A row whose outcome is unknown ends in a needs-attention state and is never retried automatically. Do not build yet: No asynchronous container handling, no approvals and no export yet. ### Phase 3 · The networks that upload before they publish Build: For platforms that use a two-step model, create the media container, then poll its status field on a schedule the platform recommends until it reports finished, and only then publish. Read the account's own publishing quota endpoint before scheduling anything into a window. Trap: Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. Done when: No publish call is made until the container status reports finished, asserted in the suite with a stub that reports in-progress three times. Polling stops at the documented ceiling and the row moves to failed with the last status recorded, rather than polling forever. The account quota is read from the platform's own endpoint before scheduling and the remaining count is shown in the interface. A carousel over the documented item limit is rejected at validation time with the limit and its source named. Do not build yet: No approval flow, no export bundle and no credential lifecycle yet. ### Phase 4 · Approval, and the networks with no door Build: A review queue showing each variant as the platform will render it, with approve, edit and reject. Approval is recorded with a timestamp. For every target whose publishes_via is export, produce a dated folder with the media, the caption as a text file and a row in a CSV. Trap: Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. Done when: A variant cannot reach the queue without an approved_at timestamp, asserted in the schema and in a test. The preview renders the caption with the platform's own truncation point marked. An export target produces a dated folder plus a CSV row and never attempts a network call, tested. The model's original draft is retained alongside the approved text for every variant. Do not build yet: No credential rotation, retention or teardown yet. ### Phase 5 · Credentials, expiry and leaving Build: Store each credential with its expiry and its scopes, refresh ahead of time, and warn a configured number of days before it expires. Support revoking one account without touching the others. Export posts, variants, schedule history and media as a folder plus CSV. Trap: Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. Done when: A credential within the warning window raises a visible warning and a log entry, tested by moving the clock. A target whose credential has expired has its queue paused with a reason, and its rows are not marked failed. Revoking one account leaves every other target publishing, tested. Export produces every table plus the media folder and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Publishing through anything other than an official API, including automating a logged-in web session, because it breaks the network's own terms and gets the account you care about restricted. - Historical analytics and attribution, which needs data the APIs hand back sparsely and a warehouse to keep it in. - Team approvals, roles and asset libraries, which are the part of the paid product that justifies a per-seat price. - Video rendering and template libraries, which is a media pipeline and a different build entirely. ### README must contain - That publishing happens only through official APIs and which of your targets are export-only - That every platform limit lives in one table with a source URL and a date, and that you must recheck them - That the unique idempotency key is what prevents double posting, and what a needs-attention row means - That picture networks upload asynchronously and that publishing quota is read from the platform, not counted locally - Where media lives, how export works, and how to remove one account completely ===== AGENTS.md ===== # Agent instructions · Predis.ai indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js for the interface and a separate Node worker process, both on Postgres, One scheduled row per target with a unique idempotency key and an atomic claim, Official platform APIs only, against accounts you own, with your own credentials, One model call per platform variant, stored as a draft a person approves, Local files validated against each platform's published rules before any upload, A dated folder and CSV for every network without a publishing API. 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 a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ===== BUILD_PLAN.md ===== # Build plan · Predis.ai A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One idea, several variants, real limits A master body becomes per-platform variants that are validated against limits you can point at a source for. ### Steps 1. Put every platform limit in one table with a date A number in the code is a number nobody will ever recheck. Files: `src/db/platform-rules.ts`, `tests/no-hardcoded-limits.test.ts` 2. Validate in the editor, inline, before scheduling The worker is the worst place to discover a caption is too long. Files: `src/app/compose/page.tsx`, `src/lib/validate.ts` 3. Make the model call produce a draft, never a post One call per variant, and the output lands in a field a person edits. Files: `src/lib/draft.ts` ### Done when - [ ] No limit is hard-coded outside platform_rules - [ ] Every rule carries a source URL and a checked date - [ ] An oversize caption cannot be scheduled - [ ] Media is validated before upload ### Watch out - Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. ## Phase 2 · A queue that cannot post twice Killing the worker at any point in the publish path never produces two posts. ### Steps 1. Claim with one conditional update, not select then update Two workers and one row is the whole bug. Files: `src/worker/claim.ts`, `tests/claim-race.test.ts` 2. Derive an idempotency key and index it uniquely The database refuses the second post even when the code tries. Files: `src/db/schema.sql` 3. Never retry a call whose outcome you do not know Hand it to a person with the transcript. Files: `src/worker/publish.ts`, `tests/unknown-outcome.test.ts` ### Done when - [ ] A kill after the API call produces one post, not two - [ ] Concurrent workers never claim the same row - [ ] The unique idempotency index exists and bites - [ ] Unknown outcomes stop rather than retry ### Watch out - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. ## Phase 3 · The networks that upload before they publish An asynchronous upload is polled to completion and a publish never fires blind. ### Steps 1. Poll the container status before publishing The platform tells you to; a blind publish burns an attempt. Files: `src/worker/containers.ts`, `tests/container-poll.test.ts` 2. Read the quota endpoint, do not count your own posts Posts made from the phone count against the same window. Files: `src/lib/quota.ts` 3. Stop polling at the documented ceiling A container that never finishes is a failure, not a loop. Files: `src/worker/containers.ts` ### Done when - [ ] No publish fires before the container reports finished - [ ] Polling stops at the ceiling and records the last status - [ ] Remaining quota comes from the platform - [ ] Over-limit carousels are rejected with the source named ### Watch out - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. ## Phase 4 · Approval, and the networks with no door Nothing generated publishes without a human approving it, and the platforms without an API get an export instead of a pretence. ### Steps 1. Make approval a state, not a checkbox No configuration flag may skip it. Files: `src/app/review/page.tsx`, `src/db/schema.sql` 2. Render the preview with the real truncation point The first line is the whole post on most timelines. Files: `src/components/Preview.tsx` 3. Write an export bundle for API-less targets A folder and a reminder beats a fake integration. Files: `src/lib/export-bundle.ts` ### Done when - [ ] Nothing publishes without an approval timestamp - [ ] Previews mark the platform truncation point - [ ] Export targets make no network calls - [ ] The original draft is kept beside the edit ### Watch out - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ## Not in this build - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. ## After v1, if you want it - A per-platform preview that renders the real card, which catches more problems than any validator - A weekly digest of which limits changed since you last checked them, driven by the checked_on column - A second account on the same platform, which is where the target and credential separation earns its keep ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, variants, schedule and quota. The queue lives here. DATABASE_URL=postgres://localhost:5432/planner # Required. Your own images and video. Exported alongside the CSV, not separately. MEDIA_DIR=./data/media # Optional · secret. Draft generation only. Without it the editor still works and you write the copy. MODEL_API_KEY=sk-...redacted # Required · secret. Encrypted tokens with their expiry. Never in the database dump you share. PLATFORM_CREDENTIALS_PATH=./data/credentials.enc # Required · secret. Encrypts the credential file. Losing it means re-authorising every account. CREDENTIAL_ENC_KEY=base64:...redacted # Required. How far ahead a dying token warns. Shorter than your longest weekend away. EXPIRY_WARN_DAYS=7 # Required. Publishing workers. The claim query is what makes more than one safe. WORKER_CONCURRENCY=2
You are building a lean indie version of Predis.ai. 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 ===== # Predis.ai · indie build A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation. 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 | Next.js for the interface and a separate Node worker process, both on Postgres | publishing is a job with retries, not a request somebody waits on | | Queue | One scheduled row per target with a unique idempotency key and an atomic claim | double posting is the failure everyone in this category ships at least once | | Publishing | Official platform APIs only, against accounts you own, with your own credentials | there is no honest build that automates a network which has not published an API for it | | Drafting | One model call per platform variant, stored as a draft a person approves | unreviewed generated copy going out under your own name is how accounts get restricted | | Media | Local files validated against each platform's published rules before any upload | a container rejected after upload has already spent an attempt | | Export | A dated folder and CSV for every network without a publishing API | the honest answer there is a reminder and a clipboard | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs both the interface and the worker. - Get it: nodejs.org or a version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: The queue relies on a conditional update returning the claimed row and on a unique index, both of which you want on a real database rather than a file. - Get it: Docker, or your package manager. - Verify: A claim query with RETURNING runs and the unique index rejects a duplicate - [ ] **A platform account of the type the publishing API requires** · free, though app review may apply - Why: Most networks publish only from a business or creator account through an app you have registered, and finding that out after building the queue costs a weekend. - Get it: Convert the account in its own settings and register a developer application. - Verify: A test call publishes to the account you intended and nowhere else - [ ] **The current published limits for every platform you target** · free - Why: Caption lengths, media counts, aspect ratios and publishing quotas all change, and this build stores each one with the date you read it rather than pretending it is permanent. - Get it: Read each platform's own developer documentation and fill platform_rules. - Verify: Every row has a source URL and a checked_on date - [ ] **Which targets are api and which are export** · free - Why: Deciding this first stops the build drifting towards an unofficial integration for the one network that will not cooperate. - Get it: List your accounts and mark each publishes_via value before writing the worker. - Verify: Every target row has a value and the README lists the export-only ones ## 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: - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. - protection from API and policy changes - production codecs, rendering speed, and media templates - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows If one of those is essential to you, that is the reason to keep paying for Predis.ai, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Predis.ai 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 personal social content planner that drafts per-platform variants, holds a queue that cannot double-post, and publishes only where an official API allows it to replace Predis.ai. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js for the interface and a separate Node worker process, both on Postgres · publishing is a job with retries, not a request somebody waits on - Queue: One scheduled row per target with a unique idempotency key and an atomic claim · double posting is the failure everyone in this category ships at least once - Publishing: Official platform APIs only, against accounts you own, with your own credentials · there is no honest build that automates a network which has not published an API for it - Drafting: One model call per platform variant, stored as a draft a person approves · unreviewed generated copy going out under your own name is how accounts get restricted - Media: Local files validated against each platform's published rules before any upload · a container rejected after upload has already spent an attempt - Export: A dated folder and CSV for every network without a publishing API · the honest answer there is a reminder and a clipboard ### Data model (create this before Phase 1) - posts(id, created_at, topic, body_master, state) · one idea, before it becomes platform-specific - variants(id, post_id, platform, body, media_json, char_count, validated_at, validation_errors_json) - targets(id, platform, account_handle, credential_ref, publishes_via, added_at) · publishes_via is api or export, and there is no third value - schedule(id, variant_id, target_id, run_at, claimed_at, claimed_by, attempts, idempotency_key, remote_id, state, last_error) - containers(id, schedule_id, platform_container_id, status_code, poll_count, first_polled_at, last_polled_at) · the asynchronous upload state for the platforms that work that way - quotas(platform, account_handle, window_start, used, limit_value, checked_at) · read from the platform's own quota endpoint, never assumed from documentation you read once - platform_rules(platform, field, value, source_url, checked_on) · every character limit, aspect ratio and file type in one table with the date you last confirmed it - There is one unique index on (target_id, idempotency_key). It is the only thing standing between a worker restart and a duplicate post. ### Phase 1 · One idea, several variants, real limits Build: Store one master body and generate a variant per target platform, either by hand or with one model call. Validate every variant against the platform_rules table: length, media count, aspect ratio, file type. Show the failures inline. Nothing leaves this screen unvalidated. Trap: Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. Done when: Every limit used anywhere in the application comes from platform_rules and carries a source URL and a checked_on date, asserted by a test that fails on a hard-coded number. A variant that violates a limit cannot be scheduled and the reason names the rule and its source. A caption containing emoji and a link counts the way its platform documents, tested against the published rule. Media is validated before upload, not after, tested with a deliberately wrong aspect ratio. Do not build yet: No queue, no publishing, no credentials and no export yet. ### Phase 2 · A queue that cannot post twice Build: Claim due rows with a single conditional update returning the claimed row. Derive an idempotency key from the variant, the target and the scheduled time, and put a unique index on it. Record the remote id in the same transaction that marks the row done. Trap: A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. Done when: Killing the worker immediately after the API call and restarting it produces exactly one post, tested against a stub that records calls. Two workers started together never claim the same row, tested with a loop. The unique index on (target_id, idempotency_key) exists and a duplicate insert is rejected rather than handled quietly. A row whose outcome is unknown ends in a needs-attention state and is never retried automatically. Do not build yet: No asynchronous container handling, no approvals and no export yet. ### Phase 3 · The networks that upload before they publish Build: For platforms that use a two-step model, create the media container, then poll its status field on a schedule the platform recommends until it reports finished, and only then publish. Read the account's own publishing quota endpoint before scheduling anything into a window. Trap: Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. Done when: No publish call is made until the container status reports finished, asserted in the suite with a stub that reports in-progress three times. Polling stops at the documented ceiling and the row moves to failed with the last status recorded, rather than polling forever. The account quota is read from the platform's own endpoint before scheduling and the remaining count is shown in the interface. A carousel over the documented item limit is rejected at validation time with the limit and its source named. Do not build yet: No approval flow, no export bundle and no credential lifecycle yet. ### Phase 4 · Approval, and the networks with no door Build: A review queue showing each variant as the platform will render it, with approve, edit and reject. Approval is recorded with a timestamp. For every target whose publishes_via is export, produce a dated folder with the media, the caption as a text file and a row in a CSV. Trap: Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. Done when: A variant cannot reach the queue without an approved_at timestamp, asserted in the schema and in a test. The preview renders the caption with the platform's own truncation point marked. An export target produces a dated folder plus a CSV row and never attempts a network call, tested. The model's original draft is retained alongside the approved text for every variant. Do not build yet: No credential rotation, retention or teardown yet. ### Phase 5 · Credentials, expiry and leaving Build: Store each credential with its expiry and its scopes, refresh ahead of time, and warn a configured number of days before it expires. Support revoking one account without touching the others. Export posts, variants, schedule history and media as a folder plus CSV. Trap: Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. Done when: A credential within the warning window raises a visible warning and a log entry, tested by moving the clock. A target whose credential has expired has its queue paused with a reason, and its rows are not marked failed. Revoking one account leaves every other target publishing, tested. Export produces every table plus the media folder and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Publishing through anything other than an official API, including automating a logged-in web session, because it breaks the network's own terms and gets the account you care about restricted. - Historical analytics and attribution, which needs data the APIs hand back sparsely and a warehouse to keep it in. - Team approvals, roles and asset libraries, which are the part of the paid product that justifies a per-seat price. - Video rendering and template libraries, which is a media pipeline and a different build entirely. ### README must contain - That publishing happens only through official APIs and which of your targets are export-only - That every platform limit lives in one table with a source URL and a date, and that you must recheck them - That the unique idempotency key is what prevents double posting, and what a needs-attention row means - That picture networks upload asynchronously and that publishing quota is read from the platform, not counted locally - Where media lives, how export works, and how to remove one account completely ===== AGENTS.md ===== # Agent instructions · Predis.ai indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js for the interface and a separate Node worker process, both on Postgres, One scheduled row per target with a unique idempotency key and an atomic claim, Official platform APIs only, against accounts you own, with your own credentials, One model call per platform variant, stored as a draft a person approves, Local files validated against each platform's published rules before any upload, A dated folder and CSV for every network without a publishing API. 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 a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ===== BUILD_PLAN.md ===== # Build plan · Predis.ai A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One idea, several variants, real limits A master body becomes per-platform variants that are validated against limits you can point at a source for. ### Steps 1. Put every platform limit in one table with a date A number in the code is a number nobody will ever recheck. Files: `src/db/platform-rules.ts`, `tests/no-hardcoded-limits.test.ts` 2. Validate in the editor, inline, before scheduling The worker is the worst place to discover a caption is too long. Files: `src/app/compose/page.tsx`, `src/lib/validate.ts` 3. Make the model call produce a draft, never a post One call per variant, and the output lands in a field a person edits. Files: `src/lib/draft.ts` ### Done when - [ ] No limit is hard-coded outside platform_rules - [ ] Every rule carries a source URL and a checked date - [ ] An oversize caption cannot be scheduled - [ ] Media is validated before upload ### Watch out - Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. ## Phase 2 · A queue that cannot post twice Killing the worker at any point in the publish path never produces two posts. ### Steps 1. Claim with one conditional update, not select then update Two workers and one row is the whole bug. Files: `src/worker/claim.ts`, `tests/claim-race.test.ts` 2. Derive an idempotency key and index it uniquely The database refuses the second post even when the code tries. Files: `src/db/schema.sql` 3. Never retry a call whose outcome you do not know Hand it to a person with the transcript. Files: `src/worker/publish.ts`, `tests/unknown-outcome.test.ts` ### Done when - [ ] A kill after the API call produces one post, not two - [ ] Concurrent workers never claim the same row - [ ] The unique idempotency index exists and bites - [ ] Unknown outcomes stop rather than retry ### Watch out - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. ## Phase 3 · The networks that upload before they publish An asynchronous upload is polled to completion and a publish never fires blind. ### Steps 1. Poll the container status before publishing The platform tells you to; a blind publish burns an attempt. Files: `src/worker/containers.ts`, `tests/container-poll.test.ts` 2. Read the quota endpoint, do not count your own posts Posts made from the phone count against the same window. Files: `src/lib/quota.ts` 3. Stop polling at the documented ceiling A container that never finishes is a failure, not a loop. Files: `src/worker/containers.ts` ### Done when - [ ] No publish fires before the container reports finished - [ ] Polling stops at the ceiling and records the last status - [ ] Remaining quota comes from the platform - [ ] Over-limit carousels are rejected with the source named ### Watch out - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. ## Phase 4 · Approval, and the networks with no door Nothing generated publishes without a human approving it, and the platforms without an API get an export instead of a pretence. ### Steps 1. Make approval a state, not a checkbox No configuration flag may skip it. Files: `src/app/review/page.tsx`, `src/db/schema.sql` 2. Render the preview with the real truncation point The first line is the whole post on most timelines. Files: `src/components/Preview.tsx` 3. Write an export bundle for API-less targets A folder and a reminder beats a fake integration. Files: `src/lib/export-bundle.ts` ### Done when - [ ] Nothing publishes without an approval timestamp - [ ] Previews mark the platform truncation point - [ ] Export targets make no network calls - [ ] The original draft is kept beside the edit ### Watch out - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ## Not in this build - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. ## After v1, if you want it - A per-platform preview that renders the real card, which catches more problems than any validator - A weekly digest of which limits changed since you last checked them, driven by the checked_on column - A second account on the same platform, which is where the target and credential separation earns its keep ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, variants, schedule and quota. The queue lives here. DATABASE_URL=postgres://localhost:5432/planner # Required. Your own images and video. Exported alongside the CSV, not separately. MEDIA_DIR=./data/media # Optional · secret. Draft generation only. Without it the editor still works and you write the copy. MODEL_API_KEY=sk-...redacted # Required · secret. Encrypted tokens with their expiry. Never in the database dump you share. PLATFORM_CREDENTIALS_PATH=./data/credentials.enc # Required · secret. Encrypts the credential file. Losing it means re-authorising every account. CREDENTIAL_ENC_KEY=base64:...redacted # Required. How far ahead a dying token warns. Shorter than your longest weekend away. EXPIRY_WARN_DAYS=7 # Required. Publishing workers. The claim query is what makes more than one safe. WORKER_CONCURRENCY=2
You are building a production product version of Predis.ai. 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 ===== # Predis.ai · product brief ## Problem The visible AI social content loop is buildable, but a credible replacement needs more than the first screen. Predis.ai earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A planner that drafts per-platform variants against limits with dates on them, holds a queue that cannot double-post, polls asynchronous uploads to completion, and gives you a dated export folder for every network it cannot honestly automate. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - hosted database and worker - official platform OAuth credentials where available ## Explicit non-goals for v1 - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. - protection from API and policy changes - production codecs, rendering speed, and media templates - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows ## Success criteria - A worker kill after the API call is confirmed to produce one post - Two concurrent workers are confirmed never to claim one row - No publish is confirmed to fire before a container reports finished - Nothing is confirmed to publish without an approval timestamp - Export and reimport are confirmed to round-trip with media ===== BRIEF.md ===== # Build brief · Predis.ai 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 personal social content planner that drafts per-platform variants, holds a queue that cannot double-post, and publishes only where an official API allows it to replace Predis.ai. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js for the interface and a separate Node worker process, both on Postgres · publishing is a job with retries, not a request somebody waits on - Queue: One scheduled row per target with a unique idempotency key and an atomic claim · double posting is the failure everyone in this category ships at least once - Publishing: Official platform APIs only, against accounts you own, with your own credentials · there is no honest build that automates a network which has not published an API for it - Drafting: One model call per platform variant, stored as a draft a person approves · unreviewed generated copy going out under your own name is how accounts get restricted - Media: Local files validated against each platform's published rules before any upload · a container rejected after upload has already spent an attempt - Export: A dated folder and CSV for every network without a publishing API · the honest answer there is a reminder and a clipboard ### Data model (create this before Phase 1) - posts(id, created_at, topic, body_master, state) · one idea, before it becomes platform-specific - variants(id, post_id, platform, body, media_json, char_count, validated_at, validation_errors_json) - targets(id, platform, account_handle, credential_ref, publishes_via, added_at) · publishes_via is api or export, and there is no third value - schedule(id, variant_id, target_id, run_at, claimed_at, claimed_by, attempts, idempotency_key, remote_id, state, last_error) - containers(id, schedule_id, platform_container_id, status_code, poll_count, first_polled_at, last_polled_at) · the asynchronous upload state for the platforms that work that way - quotas(platform, account_handle, window_start, used, limit_value, checked_at) · read from the platform's own quota endpoint, never assumed from documentation you read once - platform_rules(platform, field, value, source_url, checked_on) · every character limit, aspect ratio and file type in one table with the date you last confirmed it - There is one unique index on (target_id, idempotency_key). It is the only thing standing between a worker restart and a duplicate post. ### Phase 1 · One idea, several variants, real limits Build: Store one master body and generate a variant per target platform, either by hand or with one model call. Validate every variant against the platform_rules table: length, media count, aspect ratio, file type. Show the failures inline. Nothing leaves this screen unvalidated. Trap: Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. Done when: Every limit used anywhere in the application comes from platform_rules and carries a source URL and a checked_on date, asserted by a test that fails on a hard-coded number. A variant that violates a limit cannot be scheduled and the reason names the rule and its source. A caption containing emoji and a link counts the way its platform documents, tested against the published rule. Media is validated before upload, not after, tested with a deliberately wrong aspect ratio. Do not build yet: No queue, no publishing, no credentials and no export yet. ### Phase 2 · A queue that cannot post twice Build: Claim due rows with a single conditional update returning the claimed row. Derive an idempotency key from the variant, the target and the scheduled time, and put a unique index on it. Record the remote id in the same transaction that marks the row done. Trap: A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. Done when: Killing the worker immediately after the API call and restarting it produces exactly one post, tested against a stub that records calls. Two workers started together never claim the same row, tested with a loop. The unique index on (target_id, idempotency_key) exists and a duplicate insert is rejected rather than handled quietly. A row whose outcome is unknown ends in a needs-attention state and is never retried automatically. Do not build yet: No asynchronous container handling, no approvals and no export yet. ### Phase 3 · The networks that upload before they publish Build: For platforms that use a two-step model, create the media container, then poll its status field on a schedule the platform recommends until it reports finished, and only then publish. Read the account's own publishing quota endpoint before scheduling anything into a window. Trap: Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. Done when: No publish call is made until the container status reports finished, asserted in the suite with a stub that reports in-progress three times. Polling stops at the documented ceiling and the row moves to failed with the last status recorded, rather than polling forever. The account quota is read from the platform's own endpoint before scheduling and the remaining count is shown in the interface. A carousel over the documented item limit is rejected at validation time with the limit and its source named. Do not build yet: No approval flow, no export bundle and no credential lifecycle yet. ### Phase 4 · Approval, and the networks with no door Build: A review queue showing each variant as the platform will render it, with approve, edit and reject. Approval is recorded with a timestamp. For every target whose publishes_via is export, produce a dated folder with the media, the caption as a text file and a row in a CSV. Trap: Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. Done when: A variant cannot reach the queue without an approved_at timestamp, asserted in the schema and in a test. The preview renders the caption with the platform's own truncation point marked. An export target produces a dated folder plus a CSV row and never attempts a network call, tested. The model's original draft is retained alongside the approved text for every variant. Do not build yet: No credential rotation, retention or teardown yet. ### Phase 5 · Credentials, expiry and leaving Build: Store each credential with its expiry and its scopes, refresh ahead of time, and warn a configured number of days before it expires. Support revoking one account without touching the others. Export posts, variants, schedule history and media as a folder plus CSV. Trap: Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. Done when: A credential within the warning window raises a visible warning and a log entry, tested by moving the clock. A target whose credential has expired has its queue paused with a reason, and its rows are not marked failed. Revoking one account leaves every other target publishing, tested. Export produces every table plus the media folder and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Publishing through anything other than an official API, including automating a logged-in web session, because it breaks the network's own terms and gets the account you care about restricted. - Historical analytics and attribution, which needs data the APIs hand back sparsely and a warehouse to keep it in. - Team approvals, roles and asset libraries, which are the part of the paid product that justifies a per-seat price. - Video rendering and template libraries, which is a media pipeline and a different build entirely. ### README must contain - That publishing happens only through official APIs and which of your targets are export-only - That every platform limit lives in one table with a source URL and a date, and that you must recheck them - That the unique idempotency key is what prevents double posting, and what a needs-attention row means - That picture networks upload asynchronously and that publishing quota is read from the platform, not counted locally - Where media lives, how export works, and how to remove one account completely ===== ARCHITECTURE.md ===== # Architecture · Predis.ai ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js for the interface and a separate Node worker process, both on Postgres | publishing is a job with retries, not a request somebody waits on | | Queue | One scheduled row per target with a unique idempotency key and an atomic claim | double posting is the failure everyone in this category ships at least once | | Publishing | Official platform APIs only, against accounts you own, with your own credentials | there is no honest build that automates a network which has not published an API for it | | Drafting | One model call per platform variant, stored as a draft a person approves | unreviewed generated copy going out under your own name is how accounts get restricted | | Media | Local files validated against each platform's published rules before any upload | a container rejected after upload has already spent an attempt | | Export | A dated folder and CSV for every network without a publishing API | the honest answer there is a reminder and a clipboard | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Compose | Master body, variants, validation against platform_rules. | Any editor, provided no limit is hard-coded outside the table. | | Queue | Claiming, idempotency keys, attempts and states. | Any store with a conditional update and a unique index. | | Publishers | One adapter per platform, containers and quota reads. | Any adapter, provided it uses the official API and nothing else. | | Review | Approval state and previews. | Not swappable for an automatic approve, which is the restriction risk. | | Credentials | Encrypted tokens, expiry, revocation. | Any secret store, provided expiry is recorded and checked. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Posts, variants, schedule and quota. The queue lives here. - `MEDIA_DIR` · required · Your own images and video. Exported alongside the CSV, not separately. - `MODEL_API_KEY` · optional, secret · Draft generation only. Without it the editor still works and you write the copy. - `PLATFORM_CREDENTIALS_PATH` · required, secret · Encrypted tokens with their expiry. Never in the database dump you share. - `CREDENTIAL_ENC_KEY` · required, secret · Encrypts the credential file. Losing it means re-authorising every account. - `EXPIRY_WARN_DAYS` · required · How far ahead a dying token warns. Shorter than your longest weekend away. - `WORKER_CONCURRENCY` · required · Publishing workers. The claim query is what makes more than one safe. ## 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 · Predis.ai product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js for the interface and a separate Node worker process, both on Postgres, One scheduled row per target with a unique idempotency key and an atomic claim, Official platform APIs only, against accounts you own, with your own credentials, One model call per platform variant, stored as a draft a person approves, Local files validated against each platform's published rules before any upload, A dated folder and CSV for every network without a publishing API. - 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 a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. - Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. ===== MILESTONES.md ===== # Delivery milestones · Predis.ai Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One idea, several variants, real limits A master body becomes per-platform variants that are validated against limits you can point at a source for. ### Steps 1. Put every platform limit in one table with a date A number in the code is a number nobody will ever recheck. Files: `src/db/platform-rules.ts`, `tests/no-hardcoded-limits.test.ts` 2. Validate in the editor, inline, before scheduling The worker is the worst place to discover a caption is too long. Files: `src/app/compose/page.tsx`, `src/lib/validate.ts` 3. Make the model call produce a draft, never a post One call per variant, and the output lands in a field a person edits. Files: `src/lib/draft.ts` ### Done when - [ ] No limit is hard-coded outside platform_rules - [ ] Every rule carries a source URL and a checked date - [ ] An oversize caption cannot be scheduled - [ ] Media is validated before upload ### Watch out - Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. ## M2 · A queue that cannot post twice Killing the worker at any point in the publish path never produces two posts. ### Steps 1. Claim with one conditional update, not select then update Two workers and one row is the whole bug. Files: `src/worker/claim.ts`, `tests/claim-race.test.ts` 2. Derive an idempotency key and index it uniquely The database refuses the second post even when the code tries. Files: `src/db/schema.sql` 3. Never retry a call whose outcome you do not know Hand it to a person with the transcript. Files: `src/worker/publish.ts`, `tests/unknown-outcome.test.ts` ### Done when - [ ] A kill after the API call produces one post, not two - [ ] Concurrent workers never claim the same row - [ ] The unique idempotency index exists and bites - [ ] Unknown outcomes stop rather than retry ### Watch out - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. ## M3 · The networks that upload before they publish An asynchronous upload is polled to completion and a publish never fires blind. ### Steps 1. Poll the container status before publishing The platform tells you to; a blind publish burns an attempt. Files: `src/worker/containers.ts`, `tests/container-poll.test.ts` 2. Read the quota endpoint, do not count your own posts Posts made from the phone count against the same window. Files: `src/lib/quota.ts` 3. Stop polling at the documented ceiling A container that never finishes is a failure, not a loop. Files: `src/worker/containers.ts` ### Done when - [ ] No publish fires before the container reports finished - [ ] Polling stops at the ceiling and records the last status - [ ] Remaining quota comes from the platform - [ ] Over-limit carousels are rejected with the source named ### Watch out - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. ## M4 · Approval, and the networks with no door Nothing generated publishes without a human approving it, and the platforms without an API get an export instead of a pretence. ### Steps 1. Make approval a state, not a checkbox No configuration flag may skip it. Files: `src/app/review/page.tsx`, `src/db/schema.sql` 2. Render the preview with the real truncation point The first line is the whole post on most timelines. Files: `src/components/Preview.tsx` 3. Write an export bundle for API-less targets A folder and a reminder beats a fake integration. Files: `src/lib/export-bundle.ts` ### Done when - [ ] Nothing publishes without an approval timestamp - [ ] Previews mark the platform truncation point - [ ] Export targets make no network calls - [ ] The original draft is kept beside the edit ### Watch out - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ## M5 · Credentials, expiry and leaving (production only) A token that is about to die is a warning rather than a missed post, and everything here can be taken out. ### Steps 1. Store expiry with the token and check it daily The token, not your code, is what will break first. Files: `src/lib/credentials.ts`, `tests/expiry-warning.test.ts` 2. Pause a target's queue instead of failing its posts One reason beats forty identical failures. Files: `src/worker/pause.ts` 3. Export media and rows together and reimport them A CSV pointing at files you did not export is not an export. Files: `src/lib/export.ts`, `tests/roundtrip.test.ts` ### Done when - [ ] An approaching expiry warns before it bites - [ ] An expired credential pauses rather than fails - [ ] Revoking one account leaves the others working - [ ] Export and reimport round-trip with media ### Watch out - Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. ===== OPERATIONS.md ===== # Operations · Predis.ai ## Backup The database and the media directory together, since a CSV referring to files you did not copy is not a backup. The credential file is separate and encrypted, and restoring without its key means re-authorising every account. ## Restore Restore the database and media, confirm scheduled rows still hold their idempotency keys, then re-authorise credentials. Start the worker with publishing disabled first and check nothing due in the past fires. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rows in needs-attention, credentials inside the warning window, and remaining publishing quota per account. A rising needs-attention count almost always means a platform changed something rather than that your code broke. ## Incident checklist The incident here is a duplicate or a wrong-account post. The schedule table with its idempotency keys and remote ids is the record of what went where; delete from the platform first, then mark the row, and never fix it by editing the row and hoping. 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 - [ ] A worker kill after the API call is confirmed to produce one post - [ ] Two concurrent workers are confirmed never to claim one row - [ ] No publish is confirmed to fire before a container reports finished - [ ] Nothing is confirmed to publish without an approval timestamp - [ ] Export and reimport are confirmed to round-trip with media ## Launch constraint Do not market omitted Predis.ai 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 · secret. Posts, variants, schedule and quota. The queue lives here. DATABASE_URL=postgres://localhost:5432/planner # Required. Your own images and video. Exported alongside the CSV, not separately. MEDIA_DIR=./data/media # Optional · secret. Draft generation only. Without it the editor still works and you write the copy. MODEL_API_KEY=sk-...redacted # Required · secret. Encrypted tokens with their expiry. Never in the database dump you share. PLATFORM_CREDENTIALS_PATH=./data/credentials.enc # Required · secret. Encrypts the credential file. Losing it means re-authorising every account. CREDENTIAL_ENC_KEY=base64:...redacted # Required. How far ahead a dying token warns. Shorter than your longest weekend away. EXPIRY_WARN_DAYS=7 # Required. Publishing workers. The claim query is what makes more than one safe. WORKER_CONCURRENCY=2
# Predis.ai · indie build A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation. 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 | Next.js for the interface and a separate Node worker process, both on Postgres | publishing is a job with retries, not a request somebody waits on | | Queue | One scheduled row per target with a unique idempotency key and an atomic claim | double posting is the failure everyone in this category ships at least once | | Publishing | Official platform APIs only, against accounts you own, with your own credentials | there is no honest build that automates a network which has not published an API for it | | Drafting | One model call per platform variant, stored as a draft a person approves | unreviewed generated copy going out under your own name is how accounts get restricted | | Media | Local files validated against each platform's published rules before any upload | a container rejected after upload has already spent an attempt | | Export | A dated folder and CSV for every network without a publishing API | the honest answer there is a reminder and a clipboard | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs both the interface and the worker. - Get it: nodejs.org or a version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: The queue relies on a conditional update returning the claimed row and on a unique index, both of which you want on a real database rather than a file. - Get it: Docker, or your package manager. - Verify: A claim query with RETURNING runs and the unique index rejects a duplicate - [ ] **A platform account of the type the publishing API requires** · free, though app review may apply - Why: Most networks publish only from a business or creator account through an app you have registered, and finding that out after building the queue costs a weekend. - Get it: Convert the account in its own settings and register a developer application. - Verify: A test call publishes to the account you intended and nowhere else - [ ] **The current published limits for every platform you target** · free - Why: Caption lengths, media counts, aspect ratios and publishing quotas all change, and this build stores each one with the date you read it rather than pretending it is permanent. - Get it: Read each platform's own developer documentation and fill platform_rules. - Verify: Every row has a source URL and a checked_on date - [ ] **Which targets are api and which are export** · free - Why: Deciding this first stops the build drifting towards an unofficial integration for the one network that will not cooperate. - Get it: List your accounts and mark each publishes_via value before writing the worker. - Verify: Every target row has a value and the README lists the export-only ones ## 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: - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. - protection from API and policy changes - production codecs, rendering speed, and media templates - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows If one of those is essential to you, that is the reason to keep paying for Predis.ai, and the README should say so rather than pretend.
# Build brief · Predis.ai 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 personal social content planner that drafts per-platform variants, holds a queue that cannot double-post, and publishes only where an official API allows it to replace Predis.ai. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js for the interface and a separate Node worker process, both on Postgres · publishing is a job with retries, not a request somebody waits on - Queue: One scheduled row per target with a unique idempotency key and an atomic claim · double posting is the failure everyone in this category ships at least once - Publishing: Official platform APIs only, against accounts you own, with your own credentials · there is no honest build that automates a network which has not published an API for it - Drafting: One model call per platform variant, stored as a draft a person approves · unreviewed generated copy going out under your own name is how accounts get restricted - Media: Local files validated against each platform's published rules before any upload · a container rejected after upload has already spent an attempt - Export: A dated folder and CSV for every network without a publishing API · the honest answer there is a reminder and a clipboard ### Data model (create this before Phase 1) - posts(id, created_at, topic, body_master, state) · one idea, before it becomes platform-specific - variants(id, post_id, platform, body, media_json, char_count, validated_at, validation_errors_json) - targets(id, platform, account_handle, credential_ref, publishes_via, added_at) · publishes_via is api or export, and there is no third value - schedule(id, variant_id, target_id, run_at, claimed_at, claimed_by, attempts, idempotency_key, remote_id, state, last_error) - containers(id, schedule_id, platform_container_id, status_code, poll_count, first_polled_at, last_polled_at) · the asynchronous upload state for the platforms that work that way - quotas(platform, account_handle, window_start, used, limit_value, checked_at) · read from the platform's own quota endpoint, never assumed from documentation you read once - platform_rules(platform, field, value, source_url, checked_on) · every character limit, aspect ratio and file type in one table with the date you last confirmed it - There is one unique index on (target_id, idempotency_key). It is the only thing standing between a worker restart and a duplicate post. ### Phase 1 · One idea, several variants, real limits Build: Store one master body and generate a variant per target platform, either by hand or with one model call. Validate every variant against the platform_rules table: length, media count, aspect ratio, file type. Show the failures inline. Nothing leaves this screen unvalidated. Trap: Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. Done when: Every limit used anywhere in the application comes from platform_rules and carries a source URL and a checked_on date, asserted by a test that fails on a hard-coded number. A variant that violates a limit cannot be scheduled and the reason names the rule and its source. A caption containing emoji and a link counts the way its platform documents, tested against the published rule. Media is validated before upload, not after, tested with a deliberately wrong aspect ratio. Do not build yet: No queue, no publishing, no credentials and no export yet. ### Phase 2 · A queue that cannot post twice Build: Claim due rows with a single conditional update returning the claimed row. Derive an idempotency key from the variant, the target and the scheduled time, and put a unique index on it. Record the remote id in the same transaction that marks the row done. Trap: A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. Done when: Killing the worker immediately after the API call and restarting it produces exactly one post, tested against a stub that records calls. Two workers started together never claim the same row, tested with a loop. The unique index on (target_id, idempotency_key) exists and a duplicate insert is rejected rather than handled quietly. A row whose outcome is unknown ends in a needs-attention state and is never retried automatically. Do not build yet: No asynchronous container handling, no approvals and no export yet. ### Phase 3 · The networks that upload before they publish Build: For platforms that use a two-step model, create the media container, then poll its status field on a schedule the platform recommends until it reports finished, and only then publish. Read the account's own publishing quota endpoint before scheduling anything into a window. Trap: Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. Done when: No publish call is made until the container status reports finished, asserted in the suite with a stub that reports in-progress three times. Polling stops at the documented ceiling and the row moves to failed with the last status recorded, rather than polling forever. The account quota is read from the platform's own endpoint before scheduling and the remaining count is shown in the interface. A carousel over the documented item limit is rejected at validation time with the limit and its source named. Do not build yet: No approval flow, no export bundle and no credential lifecycle yet. ### Phase 4 · Approval, and the networks with no door Build: A review queue showing each variant as the platform will render it, with approve, edit and reject. Approval is recorded with a timestamp. For every target whose publishes_via is export, produce a dated folder with the media, the caption as a text file and a row in a CSV. Trap: Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. Done when: A variant cannot reach the queue without an approved_at timestamp, asserted in the schema and in a test. The preview renders the caption with the platform's own truncation point marked. An export target produces a dated folder plus a CSV row and never attempts a network call, tested. The model's original draft is retained alongside the approved text for every variant. Do not build yet: No credential rotation, retention or teardown yet. ### Phase 5 · Credentials, expiry and leaving Build: Store each credential with its expiry and its scopes, refresh ahead of time, and warn a configured number of days before it expires. Support revoking one account without touching the others. Export posts, variants, schedule history and media as a folder plus CSV. Trap: Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. Done when: A credential within the warning window raises a visible warning and a log entry, tested by moving the clock. A target whose credential has expired has its queue paused with a reason, and its rows are not marked failed. Revoking one account leaves every other target publishing, tested. Export produces every table plus the media folder and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Publishing through anything other than an official API, including automating a logged-in web session, because it breaks the network's own terms and gets the account you care about restricted. - Historical analytics and attribution, which needs data the APIs hand back sparsely and a warehouse to keep it in. - Team approvals, roles and asset libraries, which are the part of the paid product that justifies a per-seat price. - Video rendering and template libraries, which is a media pipeline and a different build entirely. ### README must contain - That publishing happens only through official APIs and which of your targets are export-only - That every platform limit lives in one table with a source URL and a date, and that you must recheck them - That the unique idempotency key is what prevents double posting, and what a needs-attention row means - That picture networks upload asynchronously and that publishing quota is read from the platform, not counted locally - Where media lives, how export works, and how to remove one account completely
# Agent instructions · Predis.ai indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js for the interface and a separate Node worker process, both on Postgres, One scheduled row per target with a unique idempotency key and an atomic claim, Official platform APIs only, against accounts you own, with your own credentials, One model call per platform variant, stored as a draft a person approves, Local files validated against each platform's published rules before any upload, A dated folder and CSV for every network without a publishing API. 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 a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed.
# Build plan · Predis.ai A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One idea, several variants, real limits A master body becomes per-platform variants that are validated against limits you can point at a source for. ### Steps 1. Put every platform limit in one table with a date A number in the code is a number nobody will ever recheck. Files: `src/db/platform-rules.ts`, `tests/no-hardcoded-limits.test.ts` 2. Validate in the editor, inline, before scheduling The worker is the worst place to discover a caption is too long. Files: `src/app/compose/page.tsx`, `src/lib/validate.ts` 3. Make the model call produce a draft, never a post One call per variant, and the output lands in a field a person edits. Files: `src/lib/draft.ts` ### Done when - [ ] No limit is hard-coded outside platform_rules - [ ] Every rule carries a source URL and a checked date - [ ] An oversize caption cannot be scheduled - [ ] Media is validated before upload ### Watch out - Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. ## Phase 2 · A queue that cannot post twice Killing the worker at any point in the publish path never produces two posts. ### Steps 1. Claim with one conditional update, not select then update Two workers and one row is the whole bug. Files: `src/worker/claim.ts`, `tests/claim-race.test.ts` 2. Derive an idempotency key and index it uniquely The database refuses the second post even when the code tries. Files: `src/db/schema.sql` 3. Never retry a call whose outcome you do not know Hand it to a person with the transcript. Files: `src/worker/publish.ts`, `tests/unknown-outcome.test.ts` ### Done when - [ ] A kill after the API call produces one post, not two - [ ] Concurrent workers never claim the same row - [ ] The unique idempotency index exists and bites - [ ] Unknown outcomes stop rather than retry ### Watch out - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. ## Phase 3 · The networks that upload before they publish An asynchronous upload is polled to completion and a publish never fires blind. ### Steps 1. Poll the container status before publishing The platform tells you to; a blind publish burns an attempt. Files: `src/worker/containers.ts`, `tests/container-poll.test.ts` 2. Read the quota endpoint, do not count your own posts Posts made from the phone count against the same window. Files: `src/lib/quota.ts` 3. Stop polling at the documented ceiling A container that never finishes is a failure, not a loop. Files: `src/worker/containers.ts` ### Done when - [ ] No publish fires before the container reports finished - [ ] Polling stops at the ceiling and records the last status - [ ] Remaining quota comes from the platform - [ ] Over-limit carousels are rejected with the source named ### Watch out - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. ## Phase 4 · Approval, and the networks with no door Nothing generated publishes without a human approving it, and the platforms without an API get an export instead of a pretence. ### Steps 1. Make approval a state, not a checkbox No configuration flag may skip it. Files: `src/app/review/page.tsx`, `src/db/schema.sql` 2. Render the preview with the real truncation point The first line is the whole post on most timelines. Files: `src/components/Preview.tsx` 3. Write an export bundle for API-less targets A folder and a reminder beats a fake integration. Files: `src/lib/export-bundle.ts` ### Done when - [ ] Nothing publishes without an approval timestamp - [ ] Previews mark the platform truncation point - [ ] Export targets make no network calls - [ ] The original draft is kept beside the edit ### Watch out - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ## Not in this build - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. ## After v1, if you want it - A per-platform preview that renders the real card, which catches more problems than any validator - A weekly digest of which limits changed since you last checked them, driven by the checked_on column - A second account on the same platform, which is where the target and credential separation earns its keep
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Posts, variants, schedule and quota. The queue lives here. DATABASE_URL=postgres://localhost:5432/planner # Required. Your own images and video. Exported alongside the CSV, not separately. MEDIA_DIR=./data/media # Optional · secret. Draft generation only. Without it the editor still works and you write the copy. MODEL_API_KEY=sk-...redacted # Required · secret. Encrypted tokens with their expiry. Never in the database dump you share. PLATFORM_CREDENTIALS_PATH=./data/credentials.enc # Required · secret. Encrypts the credential file. Losing it means re-authorising every account. CREDENTIAL_ENC_KEY=base64:...redacted # Required. How far ahead a dying token warns. Shorter than your longest weekend away. EXPIRY_WARN_DAYS=7 # Required. Publishing workers. The claim query is what makes more than one safe. WORKER_CONCURRENCY=2
# Predis.ai · product brief ## Problem The visible AI social content loop is buildable, but a credible replacement needs more than the first screen. Predis.ai earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A planner that drafts per-platform variants against limits with dates on them, holds a queue that cannot double-post, polls asynchronous uploads to completion, and gives you a dated export folder for every network it cannot honestly automate. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - hosted database and worker - official platform OAuth credentials where available ## Explicit non-goals for v1 - Publishing through anything but an official API. - Historical analytics and attribution. - Team approvals and asset libraries. - Video rendering and template libraries. - protection from API and policy changes - production codecs, rendering speed, and media templates - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows ## Success criteria - A worker kill after the API call is confirmed to produce one post - Two concurrent workers are confirmed never to claim one row - No publish is confirmed to fire before a container reports finished - Nothing is confirmed to publish without an approval timestamp - Export and reimport are confirmed to round-trip with media
# Build brief · Predis.ai 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 personal social content planner that drafts per-platform variants, holds a queue that cannot double-post, and publishes only where an official API allows it to replace Predis.ai. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js for the interface and a separate Node worker process, both on Postgres · publishing is a job with retries, not a request somebody waits on - Queue: One scheduled row per target with a unique idempotency key and an atomic claim · double posting is the failure everyone in this category ships at least once - Publishing: Official platform APIs only, against accounts you own, with your own credentials · there is no honest build that automates a network which has not published an API for it - Drafting: One model call per platform variant, stored as a draft a person approves · unreviewed generated copy going out under your own name is how accounts get restricted - Media: Local files validated against each platform's published rules before any upload · a container rejected after upload has already spent an attempt - Export: A dated folder and CSV for every network without a publishing API · the honest answer there is a reminder and a clipboard ### Data model (create this before Phase 1) - posts(id, created_at, topic, body_master, state) · one idea, before it becomes platform-specific - variants(id, post_id, platform, body, media_json, char_count, validated_at, validation_errors_json) - targets(id, platform, account_handle, credential_ref, publishes_via, added_at) · publishes_via is api or export, and there is no third value - schedule(id, variant_id, target_id, run_at, claimed_at, claimed_by, attempts, idempotency_key, remote_id, state, last_error) - containers(id, schedule_id, platform_container_id, status_code, poll_count, first_polled_at, last_polled_at) · the asynchronous upload state for the platforms that work that way - quotas(platform, account_handle, window_start, used, limit_value, checked_at) · read from the platform's own quota endpoint, never assumed from documentation you read once - platform_rules(platform, field, value, source_url, checked_on) · every character limit, aspect ratio and file type in one table with the date you last confirmed it - There is one unique index on (target_id, idempotency_key). It is the only thing standing between a worker restart and a duplicate post. ### Phase 1 · One idea, several variants, real limits Build: Store one master body and generate a variant per target platform, either by hand or with one model call. Validate every variant against the platform_rules table: length, media count, aspect ratio, file type. Show the failures inline. Nothing leaves this screen unvalidated. Trap: Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. Done when: Every limit used anywhere in the application comes from platform_rules and carries a source URL and a checked_on date, asserted by a test that fails on a hard-coded number. A variant that violates a limit cannot be scheduled and the reason names the rule and its source. A caption containing emoji and a link counts the way its platform documents, tested against the published rule. Media is validated before upload, not after, tested with a deliberately wrong aspect ratio. Do not build yet: No queue, no publishing, no credentials and no export yet. ### Phase 2 · A queue that cannot post twice Build: Claim due rows with a single conditional update returning the claimed row. Derive an idempotency key from the variant, the target and the scheduled time, and put a unique index on it. Record the remote id in the same transaction that marks the row done. Trap: A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. Done when: Killing the worker immediately after the API call and restarting it produces exactly one post, tested against a stub that records calls. Two workers started together never claim the same row, tested with a loop. The unique index on (target_id, idempotency_key) exists and a duplicate insert is rejected rather than handled quietly. A row whose outcome is unknown ends in a needs-attention state and is never retried automatically. Do not build yet: No asynchronous container handling, no approvals and no export yet. ### Phase 3 · The networks that upload before they publish Build: For platforms that use a two-step model, create the media container, then poll its status field on a schedule the platform recommends until it reports finished, and only then publish. Read the account's own publishing quota endpoint before scheduling anything into a window. Trap: Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. Done when: No publish call is made until the container status reports finished, asserted in the suite with a stub that reports in-progress three times. Polling stops at the documented ceiling and the row moves to failed with the last status recorded, rather than polling forever. The account quota is read from the platform's own endpoint before scheduling and the remaining count is shown in the interface. A carousel over the documented item limit is rejected at validation time with the limit and its source named. Do not build yet: No approval flow, no export bundle and no credential lifecycle yet. ### Phase 4 · Approval, and the networks with no door Build: A review queue showing each variant as the platform will render it, with approve, edit and reject. Approval is recorded with a timestamp. For every target whose publishes_via is export, produce a dated folder with the media, the caption as a text file and a row in a CSV. Trap: Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. Done when: A variant cannot reach the queue without an approved_at timestamp, asserted in the schema and in a test. The preview renders the caption with the platform's own truncation point marked. An export target produces a dated folder plus a CSV row and never attempts a network call, tested. The model's original draft is retained alongside the approved text for every variant. Do not build yet: No credential rotation, retention or teardown yet. ### Phase 5 · Credentials, expiry and leaving Build: Store each credential with its expiry and its scopes, refresh ahead of time, and warn a configured number of days before it expires. Support revoking one account without touching the others. Export posts, variants, schedule history and media as a folder plus CSV. Trap: Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices. Done when: A credential within the warning window raises a visible warning and a log entry, tested by moving the clock. A target whose credential has expired has its queue paused with a reason, and its rows are not marked failed. Revoking one account leaves every other target publishing, tested. Export produces every table plus the media folder and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Publishing through anything other than an official API, including automating a logged-in web session, because it breaks the network's own terms and gets the account you care about restricted. - Historical analytics and attribution, which needs data the APIs hand back sparsely and a warehouse to keep it in. - Team approvals, roles and asset libraries, which are the part of the paid product that justifies a per-seat price. - Video rendering and template libraries, which is a media pipeline and a different build entirely. ### README must contain - That publishing happens only through official APIs and which of your targets are export-only - That every platform limit lives in one table with a source URL and a date, and that you must recheck them - That the unique idempotency key is what prevents double posting, and what a needs-attention row means - That picture networks upload asynchronously and that publishing quota is read from the platform, not counted locally - Where media lives, how export works, and how to remove one account completely
# Architecture · Predis.ai ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js for the interface and a separate Node worker process, both on Postgres | publishing is a job with retries, not a request somebody waits on | | Queue | One scheduled row per target with a unique idempotency key and an atomic claim | double posting is the failure everyone in this category ships at least once | | Publishing | Official platform APIs only, against accounts you own, with your own credentials | there is no honest build that automates a network which has not published an API for it | | Drafting | One model call per platform variant, stored as a draft a person approves | unreviewed generated copy going out under your own name is how accounts get restricted | | Media | Local files validated against each platform's published rules before any upload | a container rejected after upload has already spent an attempt | | Export | A dated folder and CSV for every network without a publishing API | the honest answer there is a reminder and a clipboard | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Compose | Master body, variants, validation against platform_rules. | Any editor, provided no limit is hard-coded outside the table. | | Queue | Claiming, idempotency keys, attempts and states. | Any store with a conditional update and a unique index. | | Publishers | One adapter per platform, containers and quota reads. | Any adapter, provided it uses the official API and nothing else. | | Review | Approval state and previews. | Not swappable for an automatic approve, which is the restriction risk. | | Credentials | Encrypted tokens, expiry, revocation. | Any secret store, provided expiry is recorded and checked. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Posts, variants, schedule and quota. The queue lives here. - `MEDIA_DIR` · required · Your own images and video. Exported alongside the CSV, not separately. - `MODEL_API_KEY` · optional, secret · Draft generation only. Without it the editor still works and you write the copy. - `PLATFORM_CREDENTIALS_PATH` · required, secret · Encrypted tokens with their expiry. Never in the database dump you share. - `CREDENTIAL_ENC_KEY` · required, secret · Encrypts the credential file. Losing it means re-authorising every account. - `EXPIRY_WARN_DAYS` · required · How far ahead a dying token warns. Shorter than your longest weekend away. - `WORKER_CONCURRENCY` · required · Publishing workers. The claim query is what makes more than one safe. ## 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 · Predis.ai product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js for the interface and a separate Node worker process, both on Postgres, One scheduled row per target with a unique idempotency key and an atomic claim, Official platform APIs only, against accounts you own, with your own credentials, One model call per platform variant, stored as a draft a person approves, Local files validated against each platform's published rules before any upload, A dated folder and CSV for every network without a publishing API. - 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 a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. - Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices.
# Delivery milestones · Predis.ai Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One idea, several variants, real limits A master body becomes per-platform variants that are validated against limits you can point at a source for. ### Steps 1. Put every platform limit in one table with a date A number in the code is a number nobody will ever recheck. Files: `src/db/platform-rules.ts`, `tests/no-hardcoded-limits.test.ts` 2. Validate in the editor, inline, before scheduling The worker is the worst place to discover a caption is too long. Files: `src/app/compose/page.tsx`, `src/lib/validate.ts` 3. Make the model call produce a draft, never a post One call per variant, and the output lands in a field a person edits. Files: `src/lib/draft.ts` ### Done when - [ ] No limit is hard-coded outside platform_rules - [ ] Every rule carries a source URL and a checked date - [ ] An oversize caption cannot be scheduled - [ ] Media is validated before upload ### Watch out - Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker. ## M2 · A queue that cannot post twice Killing the worker at any point in the publish path never produces two posts. ### Steps 1. Claim with one conditional update, not select then update Two workers and one row is the whole bug. Files: `src/worker/claim.ts`, `tests/claim-race.test.ts` 2. Derive an idempotency key and index it uniquely The database refuses the second post even when the code tries. Files: `src/db/schema.sql` 3. Never retry a call whose outcome you do not know Hand it to a person with the transcript. Files: `src/worker/publish.ts`, `tests/unknown-outcome.test.ts` ### Done when - [ ] A kill after the API call produces one post, not two - [ ] Concurrent workers never claim the same row - [ ] The unique idempotency index exists and bites - [ ] Unknown outcomes stop rather than retry ### Watch out - A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again. ## M3 · The networks that upload before they publish An asynchronous upload is polled to completion and a publish never fires blind. ### Steps 1. Poll the container status before publishing The platform tells you to; a blind publish burns an attempt. Files: `src/worker/containers.ts`, `tests/container-poll.test.ts` 2. Read the quota endpoint, do not count your own posts Posts made from the phone count against the same window. Files: `src/lib/quota.ts` 3. Stop polling at the documented ceiling A container that never finishes is a failure, not a loop. Files: `src/worker/containers.ts` ### Done when - [ ] No publish fires before the container reports finished - [ ] Polling stops at the ceiling and records the last status - [ ] Remaining quota comes from the platform - [ ] Over-limit carousels are rejected with the source named ### Watch out - Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts. ## M4 · Approval, and the networks with no door Nothing generated publishes without a human approving it, and the platforms without an API get an export instead of a pretence. ### Steps 1. Make approval a state, not a checkbox No configuration flag may skip it. Files: `src/app/review/page.tsx`, `src/db/schema.sql` 2. Render the preview with the real truncation point The first line is the whole post on most timelines. Files: `src/components/Preview.tsx` 3. Write an export bundle for API-less targets A folder and a reminder beats a fake integration. Files: `src/lib/export-bundle.ts` ### Done when - [ ] Nothing publishes without an approval timestamp - [ ] Previews mark the platform truncation point - [ ] Export targets make no network calls - [ ] The original draft is kept beside the edit ### Watch out - Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed. ## M5 · Credentials, expiry and leaving (production only) A token that is about to die is a warning rather than a missed post, and everything here can be taken out. ### Steps 1. Store expiry with the token and check it daily The token, not your code, is what will break first. Files: `src/lib/credentials.ts`, `tests/expiry-warning.test.ts` 2. Pause a target's queue instead of failing its posts One reason beats forty identical failures. Files: `src/worker/pause.ts` 3. Export media and rows together and reimport them A CSV pointing at files you did not export is not an export. Files: `src/lib/export.ts`, `tests/roundtrip.test.ts` ### Done when - [ ] An approaching expiry warns before it bites - [ ] An expired credential pauses rather than fails - [ ] Revoking one account leaves the others working - [ ] Export and reimport round-trip with media ### Watch out - Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices.
# Operations · Predis.ai ## Backup The database and the media directory together, since a CSV referring to files you did not copy is not a backup. The credential file is separate and encrypted, and restoring without its key means re-authorising every account. ## Restore Restore the database and media, confirm scheduled rows still hold their idempotency keys, then re-authorise credentials. Start the worker with publishing disabled first and check nothing due in the past fires. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rows in needs-attention, credentials inside the warning window, and remaining publishing quota per account. A rising needs-attention count almost always means a platform changed something rather than that your code broke. ## Incident checklist The incident here is a duplicate or a wrong-account post. The schedule table with its idempotency keys and remote ids is the record of what went where; delete from the platform first, then mark the row, and never fix it by editing the row and hoping. 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 - [ ] A worker kill after the API call is confirmed to produce one post - [ ] Two concurrent workers are confirmed never to claim one row - [ ] No publish is confirmed to fire before a container reports finished - [ ] Nothing is confirmed to publish without an approval timestamp - [ ] Export and reimport are confirmed to round-trip with media ## Launch constraint Do not market omitted Predis.ai 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 · secret. Posts, variants, schedule and quota. The queue lives here. DATABASE_URL=postgres://localhost:5432/planner # Required. Your own images and video. Exported alongside the CSV, not separately. MEDIA_DIR=./data/media # Optional · secret. Draft generation only. Without it the editor still works and you write the copy. MODEL_API_KEY=sk-...redacted # Required · secret. Encrypted tokens with their expiry. Never in the database dump you share. PLATFORM_CREDENTIALS_PATH=./data/credentials.enc # Required · secret. Encrypts the credential file. Losing it means re-authorising every account. CREDENTIAL_ENC_KEY=base64:...redacted # Required. How far ahead a dying token warns. Shorter than your longest weekend away. EXPIRY_WARN_DAYS=7 # Required. Publishing workers. The claim query is what makes more than one safe. WORKER_CONCURRENCY=2
$ choose a build depth, inspect the files, then open the complete pack in your agent
Predis.ai: Social tools survive because networks change APIs, permissions, formats, and anti-spam rules constantly.
xprotection from API and policy changes
xproduction codecs, rendering speed, and media templates
xreliable direct publishing across networks
xanalytics history and attribution
xteam approvals and asset workflows
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Predis.ai pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| core | $32 | $19.17 | 1,300 credits/month (about 86 images or 9 videos at the page's examples), 1 brand, 10 social channels, 60 competitor-analysis runs |
| rise | $79 | $39.50 | 3,200 credits/month (about 213 images or 22 videos), 4 brands, 20 social channels, 130 competitor-analysis runs |
| enterprise+ | $249 | $211.67 | 10,000 credits/month (about 666 images or 71 videos), unlimited brands, 60 social channels, 600 competitor-analysis runs |
free tierno free tier
billingmonthly + annual; payments are non-refundable
hidden costsGeneration is credit-metered. Extra credits cost $29/month; extra brands cost $5/month on Core/Rise; extra channel bundles cost $5/month on Core, $25/month for 40 channels on Rise, or $99/month for 400 channels on Enterprise+. Fair-use controls may throttle or restrict use.
verified 2026-08-14 · source ↗
Vibecode Predis.ai
Kinda. The core of Predis.ai is buildable in a weekend with the prompt on this page, but there are real gaps: protection from API and policy changes, production codecs, rendering speed, and media templates. Read the honest list above before committing.
How much does Predis.ai cost?
Predis.ai's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Predis.ai?
Honestly: protection from API and policy changes; production codecs, rendering speed, and media templates; reliable direct publishing across networks; analytics history and attribution; team approvals and asset workflows. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Predis.ai?
Yes: Adobe Express Free (Limited monthly AI credits make posts, captions and video, then schedule them; ad intelligence and volume stay paid elsewhere.) The prompt is for when you want it exactly your way.