Vibecode Ocoya
track this build4 phases, 12 steps, beginner friendly0%The visible AI social marketing loop is buildable, but a credible replacement needs more than the first screen. Ocoya 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 Ocoya. 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 ===== # Ocoya · indie build A single-operator content planner whose hard problems are keeping per-network variants derived from one idea without them silently drifting apart, previewing against the constraints a network will actually enforce, and publishing through interfaces that mostly want a publicly reachable media URL rather than your local file. 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 with TypeScript, run locally or on one small box you own | one operator, one browser tab, no control plane | | Storage | PostgreSQL for posts and schedule, media on disk with a content hash | the same image goes to four networks and should be stored once | | Composition | One canonical idea with per-network overrides, never four independent drafts | copies drift, and drift is how the wrong caption ships | | Publishing | An adapter interface with a manual clipboard adapter as the default | most networks need more than a token, and pretending otherwise fails at the worst moment | | Scheduling | A queue table polled by one worker, with the slot stored in a named zone | a scheduler that fires twice is worse than one that fires late | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the planner and the schedule worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Holds ideas, variants and the schedule, and gives the worker the conditional update the queue depends on. - Get it: A local install or a container. - Verify: psql connects and the migration applies cleanly - [ ] **A written record of each network's current publishing rules** · free - Why: Caption limits, media hosting requirements, container expiry and per-day post caps decide what an adapter can be, and reading them afterwards means rewriting it. - Get it: Read each platform's own developer documentation on content publishing and note the limits and the date. - Verify: Every network in the config carries its limits and a checked-on date - [ ] **Developer credentials for any network you intend to automate** (optional) · free to register; some platforms require a reviewed application - Why: Without these the adapter is manual, which is a legitimate answer rather than a gap to paper over. - Get it: Register an application with the platform and complete whatever review it requires. - Verify: One test post reaches the account from your own code ## 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: - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. - protection from API and policy changes - calendar-provider edge cases and timezone correctness - 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 Ocoya, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Ocoya 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 social content planner that writes one idea, derives per-network variants, and is honest about which networks it can actually publish to to replace Ocoya. 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 with TypeScript, run locally or on one small box you own · one operator, one browser tab, no control plane - Storage: PostgreSQL for posts and schedule, media on disk with a content hash · the same image goes to four networks and should be stored once - Composition: One canonical idea with per-network overrides, never four independent drafts · copies drift, and drift is how the wrong caption ships - Publishing: An adapter interface with a manual clipboard adapter as the default · most networks need more than a token, and pretending otherwise fails at the worst moment - Scheduling: A queue table polled by one worker, with the slot stored in a named zone · a scheduler that fires twice is worse than one that fires late ### Data model (create this before Phase 1) - ideas(id, title, body, created_at, archived_at) · the single source every variant derives from - variants(id, idea_id, network, caption, override_fields_json, derived_from_revision, edited_at) · derived_from_revision is what detects drift - media(id, sha256, path, mime, width, height, duration_ms, bytes) · deduplicated by hash, referenced by variants - slots(id, variant_id, local_time, iana_zone, state, published_at, external_id, failure_reason) - networks(name, caption_limit, media_required, aspect_ratios_json, alt_text_required, publish_mode) · publish_mode is api, assisted or manual - publish_attempts(id, slot_id, at, adapter, request_summary, response_code, response_body_excerpt, outcome) - A variant is never created empty. It is derived from the idea, and the interface shows which fields have been edited away from it. ### Phase 1 · One idea, variants derived from it Build: An idea editor with body text and attached media. Selecting networks generates variants by applying that network's rules to the idea. Store the idea revision each variant was derived from. Show a badge on any variant whose idea has changed since, with a one-click re-derive that warns about overwriting local edits. Trap: Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. Done when: Editing the idea marks every variant derived from an earlier revision as stale, tested. Re-deriving a variant with local edits warns and shows what will be lost before it proceeds. A variant records which of its fields differ from the derivation, and the interface lists them. Deleting an idea leaves no orphaned variants or slots, checked with a foreign key test. Do not build yet: No previews, no schedule, no publishing. ### Phase 2 · Previews that enforce the network's rules, not yours Build: Per-network constraint records: caption limit, whether media is required, permitted aspect ratios, whether alt text is required. Validate every variant against them and render a preview at the real aspect ratio with the real truncation point. Record the date each constraint set was checked, and warn when that date is old, because these limits change without notice. Trap: Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. Done when: A variant exceeding its network's caption limit cannot reach the schedule and names the limit it broke. The preview renders at the network's aspect ratio and shows where the caption would truncate, verified against a known example. Every constraint record carries the date it was checked and the application warns when it is older than a configured age. A network requiring alt text blocks a media variant that has none. Do not build yet: No queue, no timezone handling, no publishing. ### Phase 3 · A queue that fires once Build: Store each slot as a local wall time plus an IANA zone identifier, and compute the instant when the worker polls. One worker claims due slots with a conditional update so two workers cannot claim the same row. Every attempt writes a publish_attempts row before the call. A slot in an unknown state after a crash is surfaced for a human, never retried blindly. Trap: Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. Done when: Two workers started together publish a due slot exactly once, tested by running them against the same database. A timed-out attempt leaves the slot in a needs-review state and is never retried automatically, tested with a fake adapter that hangs. The slot stores a local time and a zone, and changing the machine's own timezone does not change when it fires, tested. Every attempt has a row written before the network call. Do not build yet: No real network adapters yet, no exports. ### Phase 4 · Adapters, and being honest about which ones work Build: An adapter interface with three modes. The manual adapter is the default and produces a copy-ready caption and the exported media file. An API adapter is written only where you hold credentials and the interface actually accepts a post. Read each platform's current content publishing rules before writing an adapter, because they constrain the design more than the token does. Trap: Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. Done when: Every network's publish_mode is displayed in the schedule view and a manual-mode slot never attempts a network call, asserted in the suite. The manual adapter exports the caption and the media file and marks the slot published only after the operator confirms. Any API adapter records the constraint it was written against and the date that was checked. A network whose rules you have not read has no adapter and defaults to manual rather than to a stub that pretends. Do not build yet: No analytics, no team features, no export work yet. ### Phase 5 · Getting your content back out Build: Export ideas, variants, slots and attempts as JSON with media referenced by hash, plus a folder of those files. Import the same bundle into an empty database. Expire media that no slot references after a configured period. A refused token disables that adapter and switches its slots to manual rather than failing them. Trap: An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. Done when: Export and import round-trip ideas, variants, slots, attempts and media into an empty database with every preview still rendering, tested. Media referenced by no slot is removed after the retention period and the rows referencing it are removed with it. A token rejection switches that network's future slots to manual mode and notifies once rather than failing every slot. The import refuses a bundle whose media hashes do not match its files. Do not build yet: Nothing after this. ### Out of scope (and why) - Generated imagery and video, which is a rented model with a per-asset cost and a separate quality argument from planning. - Follower and engagement analytics, because the read side of these interfaces has its own permissions, quotas and history limits and would double the build. - Team approvals and roles, since one operator is the scope and an approval chain implies accounts, notifications and an audit trail. - Any posting that works around a platform's published publishing rules, which is how an account gets suspended rather than scheduled. ### README must contain - Which networks are automated, which are manual, and why the split falls where it does - That platform constraints are stored in data with a checked-on date, and where to update them - That some platforms fetch media themselves and therefore need a publicly reachable URL, which this build does not provide - How scheduling stores a wall time and a zone, and why moving the host machine does not move your posts - How to export everything, including the media files, and how to import it back ===== AGENTS.md ===== # Agent instructions · Ocoya indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, run locally or on one small box you own, PostgreSQL for posts and schedule, media on disk with a content hash, One canonical idea with per-network overrides, never four independent drafts, An adapter interface with a manual clipboard adapter as the default, A queue table polled by one worker, with the slot stored in a named zone. 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 - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ===== BUILD_PLAN.md ===== # Build plan · Ocoya A single-operator content planner whose hard problems are keeping per-network variants derived from one idea without them silently drifting apart, previewing against the constraints a network will actually enforce, and publishing through interfaces that mostly want a publicly reachable media URL rather than your local file. 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, variants derived from it Writing once produces a per-network draft for every selected network, and edits are visibly local to one variant. ### Steps 1. Derive variants rather than copying drafts Four independent copies is the bug, not the feature. Files: `src/lib/derive.ts`, `tests/derive.test.ts` 2. Store the idea revision on every variant It is the only way staleness can be computed rather than remembered. Files: `src/db/schema.sql` 3. Warn before a re-derive eats local edits The edit was deliberate; the overwrite should be too. Files: `src/app/idea/[id]/page.tsx` ### Done when - [ ] Editing an idea marks derived variants stale - [ ] Re-derive warns and lists what it will overwrite - [ ] Edited fields are listed per variant - [ ] Deleting an idea leaves no orphans ### Watch out - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. ## Phase 2 · Previews that enforce the network's rules, not yours A variant that would be rejected is rejected here, with the rule named. ### Steps 1. Keep constraints in data with a checked-on date Platform limits change and a constant in the code will not tell you. Files: `config/networks.json`, `src/lib/constraints.ts` 2. Render truncation, not a character counter People believe what they can see cut off. Files: `src/components/Preview.tsx` 3. Block the schedule on a failed constraint A warning at compose time is a warning nobody acts on. Files: `src/lib/validate.ts`, `tests/validate.test.ts` ### Done when - [ ] An over-length caption is blocked with the rule named - [ ] Previews show the real aspect ratio and truncation - [ ] Stale constraint dates raise a warning - [ ] Missing alt text blocks where required ### Watch out - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. ## Phase 3 · A queue that fires once A scheduled slot publishes exactly once, at the wall time you meant, in the zone you meant. ### Steps 1. Claim due slots with a conditional update Two workers and one row is the oldest scheduling bug there is. Files: `src/worker/claim.ts`, `tests/claim.test.ts` 2. Store wall time plus zone, resolve at poll time The machine's own clock setting is nobody's intended schedule. Files: `src/lib/schedule.ts` 3. Treat an unknown outcome as a human's problem A timeout is not a failure, and retrying it posts twice. Files: `src/worker/publish.ts` ### Done when - [ ] Two workers publish a due slot once - [ ] A hung adapter produces needs-review, not a retry - [ ] Changing the host timezone does not move a slot - [ ] An attempt row precedes every network call ### Watch out - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. ## Phase 4 · Adapters, and being honest about which ones work Each network is either genuinely automated or explicitly manual, and the interface never blurs the two. ### Steps 1. Make manual the default adapter, not the fallback It is the mode that works everywhere and never lies. Files: `src/adapters/manual.ts` 2. Read the platform's publishing rules before coding one Media hosting, container expiry and post caps shape the adapter. Files: `docs/adapters/README.md` 3. Show publish_mode next to every scheduled slot The operator needs to know what will happen at 9am. Files: `src/app/schedule/page.tsx` ### Done when - [ ] Manual slots never make a network call - [ ] The manual adapter exports caption and media - [ ] Each API adapter records its constraint check date - [ ] Unknown networks default to manual ### Watch out - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ## Not in this build - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. ## After v1, if you want it - A public media host you control, which is what would turn one of the manual adapters into an API adapter - A per-network best-time suggestion computed only from your own published history, with the sample size shown - A link checker that opens every URL in a scheduled caption before the slot is due ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Ideas, variants, slots and attempts. DATABASE_URL=postgres://localhost:5432/planner # Required. Content-hashed media files. Included in the export bundle. MEDIA_DIR=./data/media # Required. How long unreferenced media survives before it is removed. MEDIA_RETENTION_DAYS=180 # Required. Per-network limits and their checked-on dates. NETWORK_CONFIG_PATH=./config/networks.json # Required. Above this age the application warns that platform limits need rechecking. CONSTRAINT_STALE_DAYS=90 # Required. How often the schedule worker looks for due slots. WORKER_POLL_SECONDS=30
You are building a lean indie version of Ocoya. 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 ===== # Ocoya · indie build A single-operator content planner whose hard problems are keeping per-network variants derived from one idea without them silently drifting apart, previewing against the constraints a network will actually enforce, and publishing through interfaces that mostly want a publicly reachable media URL rather than your local file. 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 with TypeScript, run locally or on one small box you own | one operator, one browser tab, no control plane | | Storage | PostgreSQL for posts and schedule, media on disk with a content hash | the same image goes to four networks and should be stored once | | Composition | One canonical idea with per-network overrides, never four independent drafts | copies drift, and drift is how the wrong caption ships | | Publishing | An adapter interface with a manual clipboard adapter as the default | most networks need more than a token, and pretending otherwise fails at the worst moment | | Scheduling | A queue table polled by one worker, with the slot stored in a named zone | a scheduler that fires twice is worse than one that fires late | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the planner and the schedule worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Holds ideas, variants and the schedule, and gives the worker the conditional update the queue depends on. - Get it: A local install or a container. - Verify: psql connects and the migration applies cleanly - [ ] **A written record of each network's current publishing rules** · free - Why: Caption limits, media hosting requirements, container expiry and per-day post caps decide what an adapter can be, and reading them afterwards means rewriting it. - Get it: Read each platform's own developer documentation on content publishing and note the limits and the date. - Verify: Every network in the config carries its limits and a checked-on date - [ ] **Developer credentials for any network you intend to automate** (optional) · free to register; some platforms require a reviewed application - Why: Without these the adapter is manual, which is a legitimate answer rather than a gap to paper over. - Get it: Register an application with the platform and complete whatever review it requires. - Verify: One test post reaches the account from your own code ## 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: - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. - protection from API and policy changes - calendar-provider edge cases and timezone correctness - 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 Ocoya, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Ocoya 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 social content planner that writes one idea, derives per-network variants, and is honest about which networks it can actually publish to to replace Ocoya. 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 with TypeScript, run locally or on one small box you own · one operator, one browser tab, no control plane - Storage: PostgreSQL for posts and schedule, media on disk with a content hash · the same image goes to four networks and should be stored once - Composition: One canonical idea with per-network overrides, never four independent drafts · copies drift, and drift is how the wrong caption ships - Publishing: An adapter interface with a manual clipboard adapter as the default · most networks need more than a token, and pretending otherwise fails at the worst moment - Scheduling: A queue table polled by one worker, with the slot stored in a named zone · a scheduler that fires twice is worse than one that fires late ### Data model (create this before Phase 1) - ideas(id, title, body, created_at, archived_at) · the single source every variant derives from - variants(id, idea_id, network, caption, override_fields_json, derived_from_revision, edited_at) · derived_from_revision is what detects drift - media(id, sha256, path, mime, width, height, duration_ms, bytes) · deduplicated by hash, referenced by variants - slots(id, variant_id, local_time, iana_zone, state, published_at, external_id, failure_reason) - networks(name, caption_limit, media_required, aspect_ratios_json, alt_text_required, publish_mode) · publish_mode is api, assisted or manual - publish_attempts(id, slot_id, at, adapter, request_summary, response_code, response_body_excerpt, outcome) - A variant is never created empty. It is derived from the idea, and the interface shows which fields have been edited away from it. ### Phase 1 · One idea, variants derived from it Build: An idea editor with body text and attached media. Selecting networks generates variants by applying that network's rules to the idea. Store the idea revision each variant was derived from. Show a badge on any variant whose idea has changed since, with a one-click re-derive that warns about overwriting local edits. Trap: Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. Done when: Editing the idea marks every variant derived from an earlier revision as stale, tested. Re-deriving a variant with local edits warns and shows what will be lost before it proceeds. A variant records which of its fields differ from the derivation, and the interface lists them. Deleting an idea leaves no orphaned variants or slots, checked with a foreign key test. Do not build yet: No previews, no schedule, no publishing. ### Phase 2 · Previews that enforce the network's rules, not yours Build: Per-network constraint records: caption limit, whether media is required, permitted aspect ratios, whether alt text is required. Validate every variant against them and render a preview at the real aspect ratio with the real truncation point. Record the date each constraint set was checked, and warn when that date is old, because these limits change without notice. Trap: Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. Done when: A variant exceeding its network's caption limit cannot reach the schedule and names the limit it broke. The preview renders at the network's aspect ratio and shows where the caption would truncate, verified against a known example. Every constraint record carries the date it was checked and the application warns when it is older than a configured age. A network requiring alt text blocks a media variant that has none. Do not build yet: No queue, no timezone handling, no publishing. ### Phase 3 · A queue that fires once Build: Store each slot as a local wall time plus an IANA zone identifier, and compute the instant when the worker polls. One worker claims due slots with a conditional update so two workers cannot claim the same row. Every attempt writes a publish_attempts row before the call. A slot in an unknown state after a crash is surfaced for a human, never retried blindly. Trap: Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. Done when: Two workers started together publish a due slot exactly once, tested by running them against the same database. A timed-out attempt leaves the slot in a needs-review state and is never retried automatically, tested with a fake adapter that hangs. The slot stores a local time and a zone, and changing the machine's own timezone does not change when it fires, tested. Every attempt has a row written before the network call. Do not build yet: No real network adapters yet, no exports. ### Phase 4 · Adapters, and being honest about which ones work Build: An adapter interface with three modes. The manual adapter is the default and produces a copy-ready caption and the exported media file. An API adapter is written only where you hold credentials and the interface actually accepts a post. Read each platform's current content publishing rules before writing an adapter, because they constrain the design more than the token does. Trap: Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. Done when: Every network's publish_mode is displayed in the schedule view and a manual-mode slot never attempts a network call, asserted in the suite. The manual adapter exports the caption and the media file and marks the slot published only after the operator confirms. Any API adapter records the constraint it was written against and the date that was checked. A network whose rules you have not read has no adapter and defaults to manual rather than to a stub that pretends. Do not build yet: No analytics, no team features, no export work yet. ### Phase 5 · Getting your content back out Build: Export ideas, variants, slots and attempts as JSON with media referenced by hash, plus a folder of those files. Import the same bundle into an empty database. Expire media that no slot references after a configured period. A refused token disables that adapter and switches its slots to manual rather than failing them. Trap: An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. Done when: Export and import round-trip ideas, variants, slots, attempts and media into an empty database with every preview still rendering, tested. Media referenced by no slot is removed after the retention period and the rows referencing it are removed with it. A token rejection switches that network's future slots to manual mode and notifies once rather than failing every slot. The import refuses a bundle whose media hashes do not match its files. Do not build yet: Nothing after this. ### Out of scope (and why) - Generated imagery and video, which is a rented model with a per-asset cost and a separate quality argument from planning. - Follower and engagement analytics, because the read side of these interfaces has its own permissions, quotas and history limits and would double the build. - Team approvals and roles, since one operator is the scope and an approval chain implies accounts, notifications and an audit trail. - Any posting that works around a platform's published publishing rules, which is how an account gets suspended rather than scheduled. ### README must contain - Which networks are automated, which are manual, and why the split falls where it does - That platform constraints are stored in data with a checked-on date, and where to update them - That some platforms fetch media themselves and therefore need a publicly reachable URL, which this build does not provide - How scheduling stores a wall time and a zone, and why moving the host machine does not move your posts - How to export everything, including the media files, and how to import it back ===== AGENTS.md ===== # Agent instructions · Ocoya indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, run locally or on one small box you own, PostgreSQL for posts and schedule, media on disk with a content hash, One canonical idea with per-network overrides, never four independent drafts, An adapter interface with a manual clipboard adapter as the default, A queue table polled by one worker, with the slot stored in a named zone. 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 - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ===== BUILD_PLAN.md ===== # Build plan · Ocoya A single-operator content planner whose hard problems are keeping per-network variants derived from one idea without them silently drifting apart, previewing against the constraints a network will actually enforce, and publishing through interfaces that mostly want a publicly reachable media URL rather than your local file. 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, variants derived from it Writing once produces a per-network draft for every selected network, and edits are visibly local to one variant. ### Steps 1. Derive variants rather than copying drafts Four independent copies is the bug, not the feature. Files: `src/lib/derive.ts`, `tests/derive.test.ts` 2. Store the idea revision on every variant It is the only way staleness can be computed rather than remembered. Files: `src/db/schema.sql` 3. Warn before a re-derive eats local edits The edit was deliberate; the overwrite should be too. Files: `src/app/idea/[id]/page.tsx` ### Done when - [ ] Editing an idea marks derived variants stale - [ ] Re-derive warns and lists what it will overwrite - [ ] Edited fields are listed per variant - [ ] Deleting an idea leaves no orphans ### Watch out - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. ## Phase 2 · Previews that enforce the network's rules, not yours A variant that would be rejected is rejected here, with the rule named. ### Steps 1. Keep constraints in data with a checked-on date Platform limits change and a constant in the code will not tell you. Files: `config/networks.json`, `src/lib/constraints.ts` 2. Render truncation, not a character counter People believe what they can see cut off. Files: `src/components/Preview.tsx` 3. Block the schedule on a failed constraint A warning at compose time is a warning nobody acts on. Files: `src/lib/validate.ts`, `tests/validate.test.ts` ### Done when - [ ] An over-length caption is blocked with the rule named - [ ] Previews show the real aspect ratio and truncation - [ ] Stale constraint dates raise a warning - [ ] Missing alt text blocks where required ### Watch out - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. ## Phase 3 · A queue that fires once A scheduled slot publishes exactly once, at the wall time you meant, in the zone you meant. ### Steps 1. Claim due slots with a conditional update Two workers and one row is the oldest scheduling bug there is. Files: `src/worker/claim.ts`, `tests/claim.test.ts` 2. Store wall time plus zone, resolve at poll time The machine's own clock setting is nobody's intended schedule. Files: `src/lib/schedule.ts` 3. Treat an unknown outcome as a human's problem A timeout is not a failure, and retrying it posts twice. Files: `src/worker/publish.ts` ### Done when - [ ] Two workers publish a due slot once - [ ] A hung adapter produces needs-review, not a retry - [ ] Changing the host timezone does not move a slot - [ ] An attempt row precedes every network call ### Watch out - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. ## Phase 4 · Adapters, and being honest about which ones work Each network is either genuinely automated or explicitly manual, and the interface never blurs the two. ### Steps 1. Make manual the default adapter, not the fallback It is the mode that works everywhere and never lies. Files: `src/adapters/manual.ts` 2. Read the platform's publishing rules before coding one Media hosting, container expiry and post caps shape the adapter. Files: `docs/adapters/README.md` 3. Show publish_mode next to every scheduled slot The operator needs to know what will happen at 9am. Files: `src/app/schedule/page.tsx` ### Done when - [ ] Manual slots never make a network call - [ ] The manual adapter exports caption and media - [ ] Each API adapter records its constraint check date - [ ] Unknown networks default to manual ### Watch out - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ## Not in this build - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. ## After v1, if you want it - A public media host you control, which is what would turn one of the manual adapters into an API adapter - A per-network best-time suggestion computed only from your own published history, with the sample size shown - A link checker that opens every URL in a scheduled caption before the slot is due ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Ideas, variants, slots and attempts. DATABASE_URL=postgres://localhost:5432/planner # Required. Content-hashed media files. Included in the export bundle. MEDIA_DIR=./data/media # Required. How long unreferenced media survives before it is removed. MEDIA_RETENTION_DAYS=180 # Required. Per-network limits and their checked-on dates. NETWORK_CONFIG_PATH=./config/networks.json # Required. Above this age the application warns that platform limits need rechecking. CONSTRAINT_STALE_DAYS=90 # Required. How often the schedule worker looks for due slots. WORKER_POLL_SECONDS=30
You are building a production product version of Ocoya. 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 ===== # Ocoya · product brief ## Problem The visible AI social marketing loop is buildable, but a credible replacement needs more than the first screen. Ocoya earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A planner where one idea produces every network's draft, drift is visible instead of discovered, the queue fires once, and each network is either genuinely automated or plainly marked as something you will paste yourself. ## 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 - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. - protection from API and policy changes - calendar-provider edge cases and timezone correctness - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows ## Success criteria - Two workers are confirmed to publish a due slot once - A hung adapter is confirmed to produce needs-review rather than a retry - Manual-mode slots are confirmed to make no network call - An over-length caption is confirmed blocked with the limit named - A bundle import into an empty database is confirmed to render previews ===== BRIEF.md ===== # Build brief · Ocoya 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 social content planner that writes one idea, derives per-network variants, and is honest about which networks it can actually publish to to replace Ocoya. 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 with TypeScript, run locally or on one small box you own · one operator, one browser tab, no control plane - Storage: PostgreSQL for posts and schedule, media on disk with a content hash · the same image goes to four networks and should be stored once - Composition: One canonical idea with per-network overrides, never four independent drafts · copies drift, and drift is how the wrong caption ships - Publishing: An adapter interface with a manual clipboard adapter as the default · most networks need more than a token, and pretending otherwise fails at the worst moment - Scheduling: A queue table polled by one worker, with the slot stored in a named zone · a scheduler that fires twice is worse than one that fires late ### Data model (create this before Phase 1) - ideas(id, title, body, created_at, archived_at) · the single source every variant derives from - variants(id, idea_id, network, caption, override_fields_json, derived_from_revision, edited_at) · derived_from_revision is what detects drift - media(id, sha256, path, mime, width, height, duration_ms, bytes) · deduplicated by hash, referenced by variants - slots(id, variant_id, local_time, iana_zone, state, published_at, external_id, failure_reason) - networks(name, caption_limit, media_required, aspect_ratios_json, alt_text_required, publish_mode) · publish_mode is api, assisted or manual - publish_attempts(id, slot_id, at, adapter, request_summary, response_code, response_body_excerpt, outcome) - A variant is never created empty. It is derived from the idea, and the interface shows which fields have been edited away from it. ### Phase 1 · One idea, variants derived from it Build: An idea editor with body text and attached media. Selecting networks generates variants by applying that network's rules to the idea. Store the idea revision each variant was derived from. Show a badge on any variant whose idea has changed since, with a one-click re-derive that warns about overwriting local edits. Trap: Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. Done when: Editing the idea marks every variant derived from an earlier revision as stale, tested. Re-deriving a variant with local edits warns and shows what will be lost before it proceeds. A variant records which of its fields differ from the derivation, and the interface lists them. Deleting an idea leaves no orphaned variants or slots, checked with a foreign key test. Do not build yet: No previews, no schedule, no publishing. ### Phase 2 · Previews that enforce the network's rules, not yours Build: Per-network constraint records: caption limit, whether media is required, permitted aspect ratios, whether alt text is required. Validate every variant against them and render a preview at the real aspect ratio with the real truncation point. Record the date each constraint set was checked, and warn when that date is old, because these limits change without notice. Trap: Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. Done when: A variant exceeding its network's caption limit cannot reach the schedule and names the limit it broke. The preview renders at the network's aspect ratio and shows where the caption would truncate, verified against a known example. Every constraint record carries the date it was checked and the application warns when it is older than a configured age. A network requiring alt text blocks a media variant that has none. Do not build yet: No queue, no timezone handling, no publishing. ### Phase 3 · A queue that fires once Build: Store each slot as a local wall time plus an IANA zone identifier, and compute the instant when the worker polls. One worker claims due slots with a conditional update so two workers cannot claim the same row. Every attempt writes a publish_attempts row before the call. A slot in an unknown state after a crash is surfaced for a human, never retried blindly. Trap: Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. Done when: Two workers started together publish a due slot exactly once, tested by running them against the same database. A timed-out attempt leaves the slot in a needs-review state and is never retried automatically, tested with a fake adapter that hangs. The slot stores a local time and a zone, and changing the machine's own timezone does not change when it fires, tested. Every attempt has a row written before the network call. Do not build yet: No real network adapters yet, no exports. ### Phase 4 · Adapters, and being honest about which ones work Build: An adapter interface with three modes. The manual adapter is the default and produces a copy-ready caption and the exported media file. An API adapter is written only where you hold credentials and the interface actually accepts a post. Read each platform's current content publishing rules before writing an adapter, because they constrain the design more than the token does. Trap: Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. Done when: Every network's publish_mode is displayed in the schedule view and a manual-mode slot never attempts a network call, asserted in the suite. The manual adapter exports the caption and the media file and marks the slot published only after the operator confirms. Any API adapter records the constraint it was written against and the date that was checked. A network whose rules you have not read has no adapter and defaults to manual rather than to a stub that pretends. Do not build yet: No analytics, no team features, no export work yet. ### Phase 5 · Getting your content back out Build: Export ideas, variants, slots and attempts as JSON with media referenced by hash, plus a folder of those files. Import the same bundle into an empty database. Expire media that no slot references after a configured period. A refused token disables that adapter and switches its slots to manual rather than failing them. Trap: An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. Done when: Export and import round-trip ideas, variants, slots, attempts and media into an empty database with every preview still rendering, tested. Media referenced by no slot is removed after the retention period and the rows referencing it are removed with it. A token rejection switches that network's future slots to manual mode and notifies once rather than failing every slot. The import refuses a bundle whose media hashes do not match its files. Do not build yet: Nothing after this. ### Out of scope (and why) - Generated imagery and video, which is a rented model with a per-asset cost and a separate quality argument from planning. - Follower and engagement analytics, because the read side of these interfaces has its own permissions, quotas and history limits and would double the build. - Team approvals and roles, since one operator is the scope and an approval chain implies accounts, notifications and an audit trail. - Any posting that works around a platform's published publishing rules, which is how an account gets suspended rather than scheduled. ### README must contain - Which networks are automated, which are manual, and why the split falls where it does - That platform constraints are stored in data with a checked-on date, and where to update them - That some platforms fetch media themselves and therefore need a publicly reachable URL, which this build does not provide - How scheduling stores a wall time and a zone, and why moving the host machine does not move your posts - How to export everything, including the media files, and how to import it back ===== ARCHITECTURE.md ===== # Architecture · Ocoya ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, run locally or on one small box you own | one operator, one browser tab, no control plane | | Storage | PostgreSQL for posts and schedule, media on disk with a content hash | the same image goes to four networks and should be stored once | | Composition | One canonical idea with per-network overrides, never four independent drafts | copies drift, and drift is how the wrong caption ships | | Publishing | An adapter interface with a manual clipboard adapter as the default | most networks need more than a token, and pretending otherwise fails at the worst moment | | Scheduling | A queue table polled by one worker, with the slot stored in a named zone | a scheduler that fires twice is worse than one that fires late | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Compose | Ideas, derivation and drift detection. | Any editor, provided variants stay derived rather than copied. | | Constraints | Per-network limits with checked-on dates. | Any source, provided the date travels with the limit. | | Queue | Slot claiming, wall-time resolution and attempt rows. | Any queue, provided a timeout never triggers an automatic retry. | | Adapters | Manual, assisted and API publishing modes. | Any adapter, provided it never claims a mode it cannot deliver. | | Bundle | Export and import of content plus media files. | Any format, provided media travels with the JSON. | ## 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 · Ideas, variants, slots and attempts. - `MEDIA_DIR` · required · Content-hashed media files. Included in the export bundle. - `MEDIA_RETENTION_DAYS` · required · How long unreferenced media survives before it is removed. - `NETWORK_CONFIG_PATH` · required · Per-network limits and their checked-on dates. - `CONSTRAINT_STALE_DAYS` · required · Above this age the application warns that platform limits need rechecking. - `WORKER_POLL_SECONDS` · required · How often the schedule worker looks for due slots. ## 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 · Ocoya product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript, run locally or on one small box you own, PostgreSQL for posts and schedule, media on disk with a content hash, One canonical idea with per-network overrides, never four independent drafts, An adapter interface with a manual clipboard adapter as the default, A queue table polled by one worker, with the slot stored in a named zone. - 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 - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. - An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. ===== MILESTONES.md ===== # Delivery milestones · Ocoya Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One idea, variants derived from it Writing once produces a per-network draft for every selected network, and edits are visibly local to one variant. ### Steps 1. Derive variants rather than copying drafts Four independent copies is the bug, not the feature. Files: `src/lib/derive.ts`, `tests/derive.test.ts` 2. Store the idea revision on every variant It is the only way staleness can be computed rather than remembered. Files: `src/db/schema.sql` 3. Warn before a re-derive eats local edits The edit was deliberate; the overwrite should be too. Files: `src/app/idea/[id]/page.tsx` ### Done when - [ ] Editing an idea marks derived variants stale - [ ] Re-derive warns and lists what it will overwrite - [ ] Edited fields are listed per variant - [ ] Deleting an idea leaves no orphans ### Watch out - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. ## M2 · Previews that enforce the network's rules, not yours A variant that would be rejected is rejected here, with the rule named. ### Steps 1. Keep constraints in data with a checked-on date Platform limits change and a constant in the code will not tell you. Files: `config/networks.json`, `src/lib/constraints.ts` 2. Render truncation, not a character counter People believe what they can see cut off. Files: `src/components/Preview.tsx` 3. Block the schedule on a failed constraint A warning at compose time is a warning nobody acts on. Files: `src/lib/validate.ts`, `tests/validate.test.ts` ### Done when - [ ] An over-length caption is blocked with the rule named - [ ] Previews show the real aspect ratio and truncation - [ ] Stale constraint dates raise a warning - [ ] Missing alt text blocks where required ### Watch out - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. ## M3 · A queue that fires once A scheduled slot publishes exactly once, at the wall time you meant, in the zone you meant. ### Steps 1. Claim due slots with a conditional update Two workers and one row is the oldest scheduling bug there is. Files: `src/worker/claim.ts`, `tests/claim.test.ts` 2. Store wall time plus zone, resolve at poll time The machine's own clock setting is nobody's intended schedule. Files: `src/lib/schedule.ts` 3. Treat an unknown outcome as a human's problem A timeout is not a failure, and retrying it posts twice. Files: `src/worker/publish.ts` ### Done when - [ ] Two workers publish a due slot once - [ ] A hung adapter produces needs-review, not a retry - [ ] Changing the host timezone does not move a slot - [ ] An attempt row precedes every network call ### Watch out - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. ## M4 · Adapters, and being honest about which ones work Each network is either genuinely automated or explicitly manual, and the interface never blurs the two. ### Steps 1. Make manual the default adapter, not the fallback It is the mode that works everywhere and never lies. Files: `src/adapters/manual.ts` 2. Read the platform's publishing rules before coding one Media hosting, container expiry and post caps shape the adapter. Files: `docs/adapters/README.md` 3. Show publish_mode next to every scheduled slot The operator needs to know what will happen at 9am. Files: `src/app/schedule/page.tsx` ### Done when - [ ] Manual slots never make a network call - [ ] The manual adapter exports caption and media - [ ] Each API adapter records its constraint check date - [ ] Unknown networks default to manual ### Watch out - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ## M5 · Getting your content back out (production only) Everything you wrote is exportable, media has a lifecycle, and tokens fail safely. ### Steps 1. Export media beside the JSON, keyed by hash A bundle without files is a bundle that restores broken. Files: `src/export/bundle.ts`, `tests/bundle.test.ts` 2. Expire unreferenced media on a schedule Video is the only thing here that will fill a disk. Files: `src/worker/retention.ts` 3. Degrade a dead adapter to manual, do not fail A revoked token should cost you a copy and paste, not a week. Files: `src/adapters/registry.ts` ### Done when - [ ] Bundle import round-trips into an empty database - [ ] Unreferenced media expires with its rows - [ ] A revoked token degrades to manual mode - [ ] A hash mismatch refuses the import ### Watch out - An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. ===== OPERATIONS.md ===== # Operations · Ocoya ## Backup The database and the media directory together, or the export bundle, which contains both. Losing the media directory alone leaves a planner full of previews that will not render. ## Restore Import the bundle into an empty database, open three previews and confirm the images appear. If they do not, the bundle was JSON only and the backup was never a backup. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Slots in needs-review, the age of the oldest constraint check, and adapters that have degraded to manual. The second number is the one that goes stale without any error being logged. ## Incident checklist The two incidents are a double post and a silent non-post. Attempt rows written before each call tell you which happened; without them a timeout is indistinguishable from a success and you will guess wrong. 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 - [ ] Two workers are confirmed to publish a due slot once - [ ] A hung adapter is confirmed to produce needs-review rather than a retry - [ ] Manual-mode slots are confirmed to make no network call - [ ] An over-length caption is confirmed blocked with the limit named - [ ] A bundle import into an empty database is confirmed to render previews ## Launch constraint Do not market omitted Ocoya 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. Ideas, variants, slots and attempts. DATABASE_URL=postgres://localhost:5432/planner # Required. Content-hashed media files. Included in the export bundle. MEDIA_DIR=./data/media # Required. How long unreferenced media survives before it is removed. MEDIA_RETENTION_DAYS=180 # Required. Per-network limits and their checked-on dates. NETWORK_CONFIG_PATH=./config/networks.json # Required. Above this age the application warns that platform limits need rechecking. CONSTRAINT_STALE_DAYS=90 # Required. How often the schedule worker looks for due slots. WORKER_POLL_SECONDS=30
# Ocoya · indie build A single-operator content planner whose hard problems are keeping per-network variants derived from one idea without them silently drifting apart, previewing against the constraints a network will actually enforce, and publishing through interfaces that mostly want a publicly reachable media URL rather than your local file. 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 with TypeScript, run locally or on one small box you own | one operator, one browser tab, no control plane | | Storage | PostgreSQL for posts and schedule, media on disk with a content hash | the same image goes to four networks and should be stored once | | Composition | One canonical idea with per-network overrides, never four independent drafts | copies drift, and drift is how the wrong caption ships | | Publishing | An adapter interface with a manual clipboard adapter as the default | most networks need more than a token, and pretending otherwise fails at the worst moment | | Scheduling | A queue table polled by one worker, with the slot stored in a named zone | a scheduler that fires twice is worse than one that fires late | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the planner and the schedule worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Holds ideas, variants and the schedule, and gives the worker the conditional update the queue depends on. - Get it: A local install or a container. - Verify: psql connects and the migration applies cleanly - [ ] **A written record of each network's current publishing rules** · free - Why: Caption limits, media hosting requirements, container expiry and per-day post caps decide what an adapter can be, and reading them afterwards means rewriting it. - Get it: Read each platform's own developer documentation on content publishing and note the limits and the date. - Verify: Every network in the config carries its limits and a checked-on date - [ ] **Developer credentials for any network you intend to automate** (optional) · free to register; some platforms require a reviewed application - Why: Without these the adapter is manual, which is a legitimate answer rather than a gap to paper over. - Get it: Register an application with the platform and complete whatever review it requires. - Verify: One test post reaches the account from your own code ## 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: - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. - protection from API and policy changes - calendar-provider edge cases and timezone correctness - 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 Ocoya, and the README should say so rather than pretend.
# Build brief · Ocoya 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 social content planner that writes one idea, derives per-network variants, and is honest about which networks it can actually publish to to replace Ocoya. 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 with TypeScript, run locally or on one small box you own · one operator, one browser tab, no control plane - Storage: PostgreSQL for posts and schedule, media on disk with a content hash · the same image goes to four networks and should be stored once - Composition: One canonical idea with per-network overrides, never four independent drafts · copies drift, and drift is how the wrong caption ships - Publishing: An adapter interface with a manual clipboard adapter as the default · most networks need more than a token, and pretending otherwise fails at the worst moment - Scheduling: A queue table polled by one worker, with the slot stored in a named zone · a scheduler that fires twice is worse than one that fires late ### Data model (create this before Phase 1) - ideas(id, title, body, created_at, archived_at) · the single source every variant derives from - variants(id, idea_id, network, caption, override_fields_json, derived_from_revision, edited_at) · derived_from_revision is what detects drift - media(id, sha256, path, mime, width, height, duration_ms, bytes) · deduplicated by hash, referenced by variants - slots(id, variant_id, local_time, iana_zone, state, published_at, external_id, failure_reason) - networks(name, caption_limit, media_required, aspect_ratios_json, alt_text_required, publish_mode) · publish_mode is api, assisted or manual - publish_attempts(id, slot_id, at, adapter, request_summary, response_code, response_body_excerpt, outcome) - A variant is never created empty. It is derived from the idea, and the interface shows which fields have been edited away from it. ### Phase 1 · One idea, variants derived from it Build: An idea editor with body text and attached media. Selecting networks generates variants by applying that network's rules to the idea. Store the idea revision each variant was derived from. Show a badge on any variant whose idea has changed since, with a one-click re-derive that warns about overwriting local edits. Trap: Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. Done when: Editing the idea marks every variant derived from an earlier revision as stale, tested. Re-deriving a variant with local edits warns and shows what will be lost before it proceeds. A variant records which of its fields differ from the derivation, and the interface lists them. Deleting an idea leaves no orphaned variants or slots, checked with a foreign key test. Do not build yet: No previews, no schedule, no publishing. ### Phase 2 · Previews that enforce the network's rules, not yours Build: Per-network constraint records: caption limit, whether media is required, permitted aspect ratios, whether alt text is required. Validate every variant against them and render a preview at the real aspect ratio with the real truncation point. Record the date each constraint set was checked, and warn when that date is old, because these limits change without notice. Trap: Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. Done when: A variant exceeding its network's caption limit cannot reach the schedule and names the limit it broke. The preview renders at the network's aspect ratio and shows where the caption would truncate, verified against a known example. Every constraint record carries the date it was checked and the application warns when it is older than a configured age. A network requiring alt text blocks a media variant that has none. Do not build yet: No queue, no timezone handling, no publishing. ### Phase 3 · A queue that fires once Build: Store each slot as a local wall time plus an IANA zone identifier, and compute the instant when the worker polls. One worker claims due slots with a conditional update so two workers cannot claim the same row. Every attempt writes a publish_attempts row before the call. A slot in an unknown state after a crash is surfaced for a human, never retried blindly. Trap: Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. Done when: Two workers started together publish a due slot exactly once, tested by running them against the same database. A timed-out attempt leaves the slot in a needs-review state and is never retried automatically, tested with a fake adapter that hangs. The slot stores a local time and a zone, and changing the machine's own timezone does not change when it fires, tested. Every attempt has a row written before the network call. Do not build yet: No real network adapters yet, no exports. ### Phase 4 · Adapters, and being honest about which ones work Build: An adapter interface with three modes. The manual adapter is the default and produces a copy-ready caption and the exported media file. An API adapter is written only where you hold credentials and the interface actually accepts a post. Read each platform's current content publishing rules before writing an adapter, because they constrain the design more than the token does. Trap: Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. Done when: Every network's publish_mode is displayed in the schedule view and a manual-mode slot never attempts a network call, asserted in the suite. The manual adapter exports the caption and the media file and marks the slot published only after the operator confirms. Any API adapter records the constraint it was written against and the date that was checked. A network whose rules you have not read has no adapter and defaults to manual rather than to a stub that pretends. Do not build yet: No analytics, no team features, no export work yet. ### Phase 5 · Getting your content back out Build: Export ideas, variants, slots and attempts as JSON with media referenced by hash, plus a folder of those files. Import the same bundle into an empty database. Expire media that no slot references after a configured period. A refused token disables that adapter and switches its slots to manual rather than failing them. Trap: An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. Done when: Export and import round-trip ideas, variants, slots, attempts and media into an empty database with every preview still rendering, tested. Media referenced by no slot is removed after the retention period and the rows referencing it are removed with it. A token rejection switches that network's future slots to manual mode and notifies once rather than failing every slot. The import refuses a bundle whose media hashes do not match its files. Do not build yet: Nothing after this. ### Out of scope (and why) - Generated imagery and video, which is a rented model with a per-asset cost and a separate quality argument from planning. - Follower and engagement analytics, because the read side of these interfaces has its own permissions, quotas and history limits and would double the build. - Team approvals and roles, since one operator is the scope and an approval chain implies accounts, notifications and an audit trail. - Any posting that works around a platform's published publishing rules, which is how an account gets suspended rather than scheduled. ### README must contain - Which networks are automated, which are manual, and why the split falls where it does - That platform constraints are stored in data with a checked-on date, and where to update them - That some platforms fetch media themselves and therefore need a publicly reachable URL, which this build does not provide - How scheduling stores a wall time and a zone, and why moving the host machine does not move your posts - How to export everything, including the media files, and how to import it back
# Agent instructions · Ocoya indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, run locally or on one small box you own, PostgreSQL for posts and schedule, media on disk with a content hash, One canonical idea with per-network overrides, never four independent drafts, An adapter interface with a manual clipboard adapter as the default, A queue table polled by one worker, with the slot stored in a named zone. 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 - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one.
# Build plan · Ocoya A single-operator content planner whose hard problems are keeping per-network variants derived from one idea without them silently drifting apart, previewing against the constraints a network will actually enforce, and publishing through interfaces that mostly want a publicly reachable media URL rather than your local file. 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, variants derived from it Writing once produces a per-network draft for every selected network, and edits are visibly local to one variant. ### Steps 1. Derive variants rather than copying drafts Four independent copies is the bug, not the feature. Files: `src/lib/derive.ts`, `tests/derive.test.ts` 2. Store the idea revision on every variant It is the only way staleness can be computed rather than remembered. Files: `src/db/schema.sql` 3. Warn before a re-derive eats local edits The edit was deliberate; the overwrite should be too. Files: `src/app/idea/[id]/page.tsx` ### Done when - [ ] Editing an idea marks derived variants stale - [ ] Re-derive warns and lists what it will overwrite - [ ] Edited fields are listed per variant - [ ] Deleting an idea leaves no orphans ### Watch out - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. ## Phase 2 · Previews that enforce the network's rules, not yours A variant that would be rejected is rejected here, with the rule named. ### Steps 1. Keep constraints in data with a checked-on date Platform limits change and a constant in the code will not tell you. Files: `config/networks.json`, `src/lib/constraints.ts` 2. Render truncation, not a character counter People believe what they can see cut off. Files: `src/components/Preview.tsx` 3. Block the schedule on a failed constraint A warning at compose time is a warning nobody acts on. Files: `src/lib/validate.ts`, `tests/validate.test.ts` ### Done when - [ ] An over-length caption is blocked with the rule named - [ ] Previews show the real aspect ratio and truncation - [ ] Stale constraint dates raise a warning - [ ] Missing alt text blocks where required ### Watch out - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. ## Phase 3 · A queue that fires once A scheduled slot publishes exactly once, at the wall time you meant, in the zone you meant. ### Steps 1. Claim due slots with a conditional update Two workers and one row is the oldest scheduling bug there is. Files: `src/worker/claim.ts`, `tests/claim.test.ts` 2. Store wall time plus zone, resolve at poll time The machine's own clock setting is nobody's intended schedule. Files: `src/lib/schedule.ts` 3. Treat an unknown outcome as a human's problem A timeout is not a failure, and retrying it posts twice. Files: `src/worker/publish.ts` ### Done when - [ ] Two workers publish a due slot once - [ ] A hung adapter produces needs-review, not a retry - [ ] Changing the host timezone does not move a slot - [ ] An attempt row precedes every network call ### Watch out - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. ## Phase 4 · Adapters, and being honest about which ones work Each network is either genuinely automated or explicitly manual, and the interface never blurs the two. ### Steps 1. Make manual the default adapter, not the fallback It is the mode that works everywhere and never lies. Files: `src/adapters/manual.ts` 2. Read the platform's publishing rules before coding one Media hosting, container expiry and post caps shape the adapter. Files: `docs/adapters/README.md` 3. Show publish_mode next to every scheduled slot The operator needs to know what will happen at 9am. Files: `src/app/schedule/page.tsx` ### Done when - [ ] Manual slots never make a network call - [ ] The manual adapter exports caption and media - [ ] Each API adapter records its constraint check date - [ ] Unknown networks default to manual ### Watch out - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ## Not in this build - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. ## After v1, if you want it - A public media host you control, which is what would turn one of the manual adapters into an API adapter - A per-network best-time suggestion computed only from your own published history, with the sample size shown - A link checker that opens every URL in a scheduled caption before the slot is due
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Ideas, variants, slots and attempts. DATABASE_URL=postgres://localhost:5432/planner # Required. Content-hashed media files. Included in the export bundle. MEDIA_DIR=./data/media # Required. How long unreferenced media survives before it is removed. MEDIA_RETENTION_DAYS=180 # Required. Per-network limits and their checked-on dates. NETWORK_CONFIG_PATH=./config/networks.json # Required. Above this age the application warns that platform limits need rechecking. CONSTRAINT_STALE_DAYS=90 # Required. How often the schedule worker looks for due slots. WORKER_POLL_SECONDS=30
# Ocoya · product brief ## Problem The visible AI social marketing loop is buildable, but a credible replacement needs more than the first screen. Ocoya earns its keep through platform APIs, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A planner where one idea produces every network's draft, drift is visible instead of discovered, the queue fires once, and each network is either genuinely automated or plainly marked as something you will paste yourself. ## 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 - Generated imagery and video. - Follower and engagement analytics. - Team approvals and roles. - Any publishing route outside a platform's published rules. - protection from API and policy changes - calendar-provider edge cases and timezone correctness - reliable direct publishing across networks - analytics history and attribution - team approvals and asset workflows ## Success criteria - Two workers are confirmed to publish a due slot once - A hung adapter is confirmed to produce needs-review rather than a retry - Manual-mode slots are confirmed to make no network call - An over-length caption is confirmed blocked with the limit named - A bundle import into an empty database is confirmed to render previews
# Build brief · Ocoya 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 social content planner that writes one idea, derives per-network variants, and is honest about which networks it can actually publish to to replace Ocoya. 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 with TypeScript, run locally or on one small box you own · one operator, one browser tab, no control plane - Storage: PostgreSQL for posts and schedule, media on disk with a content hash · the same image goes to four networks and should be stored once - Composition: One canonical idea with per-network overrides, never four independent drafts · copies drift, and drift is how the wrong caption ships - Publishing: An adapter interface with a manual clipboard adapter as the default · most networks need more than a token, and pretending otherwise fails at the worst moment - Scheduling: A queue table polled by one worker, with the slot stored in a named zone · a scheduler that fires twice is worse than one that fires late ### Data model (create this before Phase 1) - ideas(id, title, body, created_at, archived_at) · the single source every variant derives from - variants(id, idea_id, network, caption, override_fields_json, derived_from_revision, edited_at) · derived_from_revision is what detects drift - media(id, sha256, path, mime, width, height, duration_ms, bytes) · deduplicated by hash, referenced by variants - slots(id, variant_id, local_time, iana_zone, state, published_at, external_id, failure_reason) - networks(name, caption_limit, media_required, aspect_ratios_json, alt_text_required, publish_mode) · publish_mode is api, assisted or manual - publish_attempts(id, slot_id, at, adapter, request_summary, response_code, response_body_excerpt, outcome) - A variant is never created empty. It is derived from the idea, and the interface shows which fields have been edited away from it. ### Phase 1 · One idea, variants derived from it Build: An idea editor with body text and attached media. Selecting networks generates variants by applying that network's rules to the idea. Store the idea revision each variant was derived from. Show a badge on any variant whose idea has changed since, with a one-click re-derive that warns about overwriting local edits. Trap: Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. Done when: Editing the idea marks every variant derived from an earlier revision as stale, tested. Re-deriving a variant with local edits warns and shows what will be lost before it proceeds. A variant records which of its fields differ from the derivation, and the interface lists them. Deleting an idea leaves no orphaned variants or slots, checked with a foreign key test. Do not build yet: No previews, no schedule, no publishing. ### Phase 2 · Previews that enforce the network's rules, not yours Build: Per-network constraint records: caption limit, whether media is required, permitted aspect ratios, whether alt text is required. Validate every variant against them and render a preview at the real aspect ratio with the real truncation point. Record the date each constraint set was checked, and warn when that date is old, because these limits change without notice. Trap: Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. Done when: A variant exceeding its network's caption limit cannot reach the schedule and names the limit it broke. The preview renders at the network's aspect ratio and shows where the caption would truncate, verified against a known example. Every constraint record carries the date it was checked and the application warns when it is older than a configured age. A network requiring alt text blocks a media variant that has none. Do not build yet: No queue, no timezone handling, no publishing. ### Phase 3 · A queue that fires once Build: Store each slot as a local wall time plus an IANA zone identifier, and compute the instant when the worker polls. One worker claims due slots with a conditional update so two workers cannot claim the same row. Every attempt writes a publish_attempts row before the call. A slot in an unknown state after a crash is surfaced for a human, never retried blindly. Trap: Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. Done when: Two workers started together publish a due slot exactly once, tested by running them against the same database. A timed-out attempt leaves the slot in a needs-review state and is never retried automatically, tested with a fake adapter that hangs. The slot stores a local time and a zone, and changing the machine's own timezone does not change when it fires, tested. Every attempt has a row written before the network call. Do not build yet: No real network adapters yet, no exports. ### Phase 4 · Adapters, and being honest about which ones work Build: An adapter interface with three modes. The manual adapter is the default and produces a copy-ready caption and the exported media file. An API adapter is written only where you hold credentials and the interface actually accepts a post. Read each platform's current content publishing rules before writing an adapter, because they constrain the design more than the token does. Trap: Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. Done when: Every network's publish_mode is displayed in the schedule view and a manual-mode slot never attempts a network call, asserted in the suite. The manual adapter exports the caption and the media file and marks the slot published only after the operator confirms. Any API adapter records the constraint it was written against and the date that was checked. A network whose rules you have not read has no adapter and defaults to manual rather than to a stub that pretends. Do not build yet: No analytics, no team features, no export work yet. ### Phase 5 · Getting your content back out Build: Export ideas, variants, slots and attempts as JSON with media referenced by hash, plus a folder of those files. Import the same bundle into an empty database. Expire media that no slot references after a configured period. A refused token disables that adapter and switches its slots to manual rather than failing them. Trap: An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview. Done when: Export and import round-trip ideas, variants, slots, attempts and media into an empty database with every preview still rendering, tested. Media referenced by no slot is removed after the retention period and the rows referencing it are removed with it. A token rejection switches that network's future slots to manual mode and notifies once rather than failing every slot. The import refuses a bundle whose media hashes do not match its files. Do not build yet: Nothing after this. ### Out of scope (and why) - Generated imagery and video, which is a rented model with a per-asset cost and a separate quality argument from planning. - Follower and engagement analytics, because the read side of these interfaces has its own permissions, quotas and history limits and would double the build. - Team approvals and roles, since one operator is the scope and an approval chain implies accounts, notifications and an audit trail. - Any posting that works around a platform's published publishing rules, which is how an account gets suspended rather than scheduled. ### README must contain - Which networks are automated, which are manual, and why the split falls where it does - That platform constraints are stored in data with a checked-on date, and where to update them - That some platforms fetch media themselves and therefore need a publicly reachable URL, which this build does not provide - How scheduling stores a wall time and a zone, and why moving the host machine does not move your posts - How to export everything, including the media files, and how to import it back
# Architecture · Ocoya ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, run locally or on one small box you own | one operator, one browser tab, no control plane | | Storage | PostgreSQL for posts and schedule, media on disk with a content hash | the same image goes to four networks and should be stored once | | Composition | One canonical idea with per-network overrides, never four independent drafts | copies drift, and drift is how the wrong caption ships | | Publishing | An adapter interface with a manual clipboard adapter as the default | most networks need more than a token, and pretending otherwise fails at the worst moment | | Scheduling | A queue table polled by one worker, with the slot stored in a named zone | a scheduler that fires twice is worse than one that fires late | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Compose | Ideas, derivation and drift detection. | Any editor, provided variants stay derived rather than copied. | | Constraints | Per-network limits with checked-on dates. | Any source, provided the date travels with the limit. | | Queue | Slot claiming, wall-time resolution and attempt rows. | Any queue, provided a timeout never triggers an automatic retry. | | Adapters | Manual, assisted and API publishing modes. | Any adapter, provided it never claims a mode it cannot deliver. | | Bundle | Export and import of content plus media files. | Any format, provided media travels with the JSON. | ## 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 · Ideas, variants, slots and attempts. - `MEDIA_DIR` · required · Content-hashed media files. Included in the export bundle. - `MEDIA_RETENTION_DAYS` · required · How long unreferenced media survives before it is removed. - `NETWORK_CONFIG_PATH` · required · Per-network limits and their checked-on dates. - `CONSTRAINT_STALE_DAYS` · required · Above this age the application warns that platform limits need rechecking. - `WORKER_POLL_SECONDS` · required · How often the schedule worker looks for due slots. ## 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 · Ocoya product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript, run locally or on one small box you own, PostgreSQL for posts and schedule, media on disk with a content hash, One canonical idea with per-network overrides, never four independent drafts, An adapter interface with a manual clipboard adapter as the default, A queue table polled by one worker, with the slot stored in a named zone. - 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 - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. - An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview.
# Delivery milestones · Ocoya Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One idea, variants derived from it Writing once produces a per-network draft for every selected network, and edits are visibly local to one variant. ### Steps 1. Derive variants rather than copying drafts Four independent copies is the bug, not the feature. Files: `src/lib/derive.ts`, `tests/derive.test.ts` 2. Store the idea revision on every variant It is the only way staleness can be computed rather than remembered. Files: `src/db/schema.sql` 3. Warn before a re-derive eats local edits The edit was deliberate; the overwrite should be too. Files: `src/app/idea/[id]/page.tsx` ### Done when - [ ] Editing an idea marks derived variants stale - [ ] Re-derive warns and lists what it will overwrite - [ ] Edited fields are listed per variant - [ ] Deleting an idea leaves no orphans ### Watch out - Creating four independent drafts at the start and letting the user edit each feels simpler for a day and then produces the classic failure, where the typo is fixed on three networks and lives forever on the fourth. Derive variants from one idea, keep the source revision on each, and make staleness visible rather than something you remember. ## M2 · Previews that enforce the network's rules, not yours A variant that would be rejected is rejected here, with the rule named. ### Steps 1. Keep constraints in data with a checked-on date Platform limits change and a constant in the code will not tell you. Files: `config/networks.json`, `src/lib/constraints.ts` 2. Render truncation, not a character counter People believe what they can see cut off. Files: `src/components/Preview.tsx` 3. Block the schedule on a failed constraint A warning at compose time is a warning nobody acts on. Files: `src/lib/validate.ts`, `tests/validate.test.ts` ### Done when - [ ] An over-length caption is blocked with the rule named - [ ] Previews show the real aspect ratio and truncation - [ ] Stale constraint dates raise a warning - [ ] Missing alt text blocks where required ### Watch out - Hard-coding one caption limit for every network and trusting the editor's character counter produces a draft that looks fine and is truncated mid-sentence on the one network with a shorter limit. Keep constraints per network in data with a checked-on date, validate against them, and show the truncation in the preview rather than a count. ## M3 · A queue that fires once A scheduled slot publishes exactly once, at the wall time you meant, in the zone you meant. ### Steps 1. Claim due slots with a conditional update Two workers and one row is the oldest scheduling bug there is. Files: `src/worker/claim.ts`, `tests/claim.test.ts` 2. Store wall time plus zone, resolve at poll time The machine's own clock setting is nobody's intended schedule. Files: `src/lib/schedule.ts` 3. Treat an unknown outcome as a human's problem A timeout is not a failure, and retrying it posts twice. Files: `src/worker/publish.ts` ### Done when - [ ] Two workers publish a due slot once - [ ] A hung adapter produces needs-review, not a retry - [ ] Changing the host timezone does not move a slot - [ ] An attempt row precedes every network call ### Watch out - Retrying a slot whose network call timed out is how you post the same thing twice, because a timeout tells you nothing about whether the post was created. Write the attempt before the call, treat an unknown outcome as needing a human, and reconcile against the network before any retry. ## M4 · Adapters, and being honest about which ones work Each network is either genuinely automated or explicitly manual, and the interface never blurs the two. ### Steps 1. Make manual the default adapter, not the fallback It is the mode that works everywhere and never lies. Files: `src/adapters/manual.ts` 2. Read the platform's publishing rules before coding one Media hosting, container expiry and post caps shape the adapter. Files: `docs/adapters/README.md` 3. Show publish_mode next to every scheduled slot The operator needs to know what will happen at 9am. Files: `src/app/schedule/page.tsx` ### Done when - [ ] Manual slots never make a network call - [ ] The manual adapter exports caption and media - [ ] Each API adapter records its constraint check date - [ ] Unknown networks default to manual ### Watch out - Assuming an image upload means posting bytes to an endpoint is wrong for at least one major network: Instagram's content publishing documentation says the platform fetches the media itself, so the file must sit on a publicly reachable server at the moment of the attempt, the container it creates expires if not published within 24 hours, and the account is capped at a published number of API posts per rolling 24 hours. A local-only planner therefore cannot publish there without a public media host, and the honest answer is a manual adapter, not a fake one. ## M5 · Getting your content back out (production only) Everything you wrote is exportable, media has a lifecycle, and tokens fail safely. ### Steps 1. Export media beside the JSON, keyed by hash A bundle without files is a bundle that restores broken. Files: `src/export/bundle.ts`, `tests/bundle.test.ts` 2. Expire unreferenced media on a schedule Video is the only thing here that will fill a disk. Files: `src/worker/retention.ts` 3. Degrade a dead adapter to manual, do not fail A revoked token should cost you a copy and paste, not a week. Files: `src/adapters/registry.ts` ### Done when - [ ] Bundle import round-trips into an empty database - [ ] Unreferenced media expires with its rows - [ ] A revoked token degrades to manual mode - [ ] A hash mismatch refuses the import ### Watch out - An export that writes captions but leaves media as database ids produces a bundle that looks complete and restores into a planner full of broken image references. Export the files alongside the JSON, key them by hash, and prove it by importing into an empty database and rendering a preview.
# Operations · Ocoya ## Backup The database and the media directory together, or the export bundle, which contains both. Losing the media directory alone leaves a planner full of previews that will not render. ## Restore Import the bundle into an empty database, open three previews and confirm the images appear. If they do not, the bundle was JSON only and the backup was never a backup. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Slots in needs-review, the age of the oldest constraint check, and adapters that have degraded to manual. The second number is the one that goes stale without any error being logged. ## Incident checklist The two incidents are a double post and a silent non-post. Attempt rows written before each call tell you which happened; without them a timeout is indistinguishable from a success and you will guess wrong. 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 - [ ] Two workers are confirmed to publish a due slot once - [ ] A hung adapter is confirmed to produce needs-review rather than a retry - [ ] Manual-mode slots are confirmed to make no network call - [ ] An over-length caption is confirmed blocked with the limit named - [ ] A bundle import into an empty database is confirmed to render previews ## Launch constraint Do not market omitted Ocoya 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. Ideas, variants, slots and attempts. DATABASE_URL=postgres://localhost:5432/planner # Required. Content-hashed media files. Included in the export bundle. MEDIA_DIR=./data/media # Required. How long unreferenced media survives before it is removed. MEDIA_RETENTION_DAYS=180 # Required. Per-network limits and their checked-on dates. NETWORK_CONFIG_PATH=./config/networks.json # Required. Above this age the application warns that platform limits need rechecking. CONSTRAINT_STALE_DAYS=90 # Required. How often the schedule worker looks for due slots. WORKER_POLL_SECONDS=30
$ choose a build depth, inspect the files, then open the complete pack in your agent
Ocoya: Social tools survive because networks change APIs, permissions, formats, and anti-spam rules constantly.
xprotection from API and policy changes
xcalendar-provider edge cases and timezone correctness
xreliable direct publishing across networks
xanalytics history and attribution
xteam approvals and asset workflows
Ocoya pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| bronze | $15 | $12 | 1 workspace, 1 user, 5 social profiles, 100 credits |
| silver | $39 | $31.20 | 5 workspaces, 5 users, 20 social profiles, 500 credits |
| gold | $79 | $63.20 | 20 workspaces, 20 users, 50 social profiles, 1,500 credits |
| diamond | $159 | $127.20 | Unlimited workspaces, 50 users, 150 social profiles, unlimited credits |
| enterprise | custom | — | Custom plan for large businesses |
free tierno free tier
billingmonthly + annual (-20%); 7-day free trial
hidden costsVAT may be added; lower plans have hard monthly credit, workspace, user and social-profile caps, and the page does not publish overage prices.
verified 2026-08-14 · source ↗
Vibecode Ocoya
Kinda. The core of Ocoya is buildable in a weekend with the prompt on this page, but there are real gaps: protection from API and policy changes, calendar-provider edge cases and timezone correctness. Read the honest list above before committing.
How much does Ocoya cost?
Ocoya'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 Ocoya?
Honestly: protection from API and policy changes; calendar-provider edge cases and timezone correctness; 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 Ocoya?
Yes: Postiz (Open-source social scheduling and publishing platform.), Mixpost (Self-hosted social media management software.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.