Build Predis.ai
KINDAback to the verdict
A personal social planner whose hard problems are a queue that survives a worker restart without posting twice, the asynchronous upload model the picture networks use, and being honest that a network without a publishing API gets an export file rather than an automation.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs both the interface and the worker.
Get it nodejs.org or a version manager. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why The queue relies on a conditional update returning the claimed row and on a unique index, both of which you want on a real database rather than a file.
Get it Docker, or your package manager.
Verify
A claim query with RETURNING runs and the unique index rejects a duplicate - accountfree, though app review may apply
Why Most networks publish only from a business or creator account through an app you have registered, and finding that out after building the queue costs a weekend.
Get it Convert the account in its own settings and register a developer application.
Verify
A test call publishes to the account you intended and nowhere else - have readyfree
Why Caption lengths, media counts, aspect ratios and publishing quotas all change, and this build stores each one with the date you read it rather than pretending it is permanent.
Get it Read each platform's own developer documentation and fill platform_rules.
Verify
Every row has a source URL and a checked_on date - decidefree
Why Deciding this first stops the build drifting towards an unofficial integration for the one network that will not cooperate.
Get it List your accounts and mark each publishes_via value before writing the worker.
Verify
Every target row has a value and the README lists the export-only ones
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- posts(id, created_at, topic, body_master, state) · one idea, before it becomes platform-specific - variants(id, post_id, platform, body, media_json, char_count, validated_at, validation_errors_json) - targets(id, platform, account_handle, credential_ref, publishes_via, added_at) · publishes_via is api or export, and there is no third value - schedule(id, variant_id, target_id, run_at, claimed_at, claimed_by, attempts, idempotency_key, remote_id, state, last_error) - containers(id, schedule_id, platform_container_id, status_code, poll_count, first_polled_at, last_polled_at) · the asynchronous upload state for the platforms that work that way - quotas(platform, account_handle, window_start, used, limit_value, checked_at) · read from the platform's own quota endpoint, never assumed from documentation you read once - platform_rules(platform, field, value, source_url, checked_on) · every character limit, aspect ratio and file type in one table with the date you last confirmed it - There is one unique index on (target_id, idempotency_key). It is the only thing standing between a worker restart and a duplicate post.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/planner | Posts, variants, schedule and quota. The queue lives here. |
MEDIA_DIR | required | ./data/media | Your own images and video. Exported alongside the CSV, not separately. |
MODEL_API_KEYsecret | optional | sk-...redacted | Draft generation only. Without it the editor still works and you write the copy. |
PLATFORM_CREDENTIALS_PATHsecret | required | ./data/credentials.enc | Encrypted tokens with their expiry. Never in the database dump you share. |
CREDENTIAL_ENC_KEYsecret | required | base64:...redacted | Encrypts the credential file. Losing it means re-authorising every account. |
EXPIRY_WARN_DAYS | required | 7 | How far ahead a dying token warns. Shorter than your longest weekend away. |
WORKER_CONCURRENCY | required | 2 | Publishing workers. The claim query is what makes more than one safe. |
The build, in order
One idea, several variants, real limits
A master body becomes per-platform variants that are validated against limits you can point at a source for.
A number in the code is a number nobody will ever recheck.
Files
src/db/platform-rules.tstests/no-hardcoded-limits.test.tsThe worker is the worst place to discover a caption is too long.
Files
src/app/compose/page.tsxsrc/lib/validate.tsOne call per variant, and the output lands in a field a person edits.
Files
src/lib/draft.ts
done when · tick each as it passeswatch out- Counting a caption with the length of the string gives a different answer from the platform for emoji, combining characters and links, so a variant that looks fine locally is rejected at publish time when the slot has already been booked and the window has passed; put every limit in the platform_rules table with the source URL and the date you checked it, count the way that platform documents, and fail the variant in the editor rather than in the worker.
A queue that cannot post twice
Killing the worker at any point in the publish path never produces two posts.
Two workers and one row is the whole bug.
Files
src/worker/claim.tstests/claim-race.test.tsThe database refuses the second post even when the code tries.
Files
src/db/schema.sqlHand it to a person with the transcript.
Files
src/worker/publish.tstests/unknown-outcome.test.ts
done when · tick each as it passeswatch out- A worker killed after the platform accepted the post but before the row was marked done will, on restart, publish the same thing again, and selecting due rows then updating them lets two workers claim the same row in the first place; claim with one UPDATE that both filters and sets the claim, and when a retry finds an attempt whose outcome is unknown, do not guess, move the row to a needs-attention state and tell the person rather than posting again.
The networks that upload before they publish
An asynchronous upload is polled to completion and a publish never fires blind.
The platform tells you to; a blind publish burns an attempt.
Files
src/worker/containers.tstests/container-poll.test.tsPosts made from the phone count against the same window.
Files
src/lib/quota.tsA container that never finishes is a failure, not a loop.
Files
src/worker/containers.ts
done when · tick each as it passeswatch out- Publishing straight after creating a container fails, because the media is still being processed, and the platform documentation for this model tells you to query the container's status field before publishing and recommends polling about once a minute for no more than five minutes; worse, the account is capped at a number of API-published posts in a rolling twenty-four hour window, so a blind publish that fails can still cost you a slot, which is why you read the quota endpoint rather than counting your own posts.
Approval, and the networks with no door
Nothing generated publishes without a human approving it, and the platforms without an API get an export instead of a pretence.
No configuration flag may skip it.
Files
src/app/review/page.tsxsrc/db/schema.sqlThe first line is the whole post on most timelines.
Files
src/components/Preview.tsxA folder and a reminder beats a fake integration.
Files
src/lib/export-bundle.ts
done when · tick each as it passeswatch out- Building a path that lets a generated caption publish unattended feels like the point of the product and is the fastest way to have an account restricted, because the same model produces the same phrasings across a run and the networks are looking for exactly that; make approval a state the row must pass through, with no setting that skips it, and keep the model's original text beside the edited text so you can see what you actually changed.
Credentials, expiry and leavingproduct builder
A token that is about to die is a warning rather than a missed post, and everything here can be taken out.
The token, not your code, is what will break first.
Files
src/lib/credentials.tstests/expiry-warning.test.tsOne reason beats forty identical failures.
Files
src/worker/pause.tsA CSV pointing at files you did not export is not an export.
Files
src/lib/export.tstests/roundtrip.test.ts
done when · tick each as it passeswatch out- Discovering an expired token at publish time means the slot is already gone, and it will happen on a weekend because that is when the scheduled posts are; store expires_at when the token is issued, run a daily check that warns before the cliff, and pause a target's queue rather than failing its posts one at a time until someone notices.
That is the whole plan for Predis.ai. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Publishing through anything but an official API.
- Historical analytics and attribution.
- Team approvals and asset libraries.
- Video rendering and template libraries.
- protection from API and policy changes
- production codecs, rendering speed, and media templates
- reliable direct publishing across networks
- analytics history and attribution
- team approvals and asset workflows
- A per-platform preview that renders the real card, which catches more problems than any validator
- A weekly digest of which limits changed since you last checked them, driven by the checked_on column
- A second account on the same platform, which is where the target and credential separation earns its keep
Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.