Build FeedHive
KINDAreplaces $33.45/mosaves $401.4/yrback to the verdict
A scheduler for one author whose hard problems are that every network has a different and changing set of caps and formats, that a worker publishing a due post must do it exactly once even when the API times out, and that analytics history cannot be recovered after the fact so you either record it from day one or you do not have it.
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 the editor and the worker.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why Holds posts, schedules and the append-only metric history, which grows and needs real indexes.
Get it Your package manager or a small managed instance.
Verify
Migrations apply and the worker claims a row - accountvaries sharply by network; check each current published pricing page before you commit
Why Each network has its own review, its own scopes and its own publishing caps, and at least one of them will refuse you.
Get it Register an application on each network's developer portal and request the publishing permission.
Verify
A test post reaches each connected account through the API - installfree
Why Per-network renditions are the only way to stop an upload being rejected for a format you did not check.
Get it Your package manager.
Verify
ffmpeg -version prints a version - decidefree
Why Every refusal in this build cites a number from this table, and a table filled in from memory produces refusals that are wrong in both directions.
Get it Read each network's current publishing documentation and write the caps, windows and allowed types down with today's date.
Verify
Every row has a checked_on date and the editor shows it
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- posts(id, title, body_master, created_at, archived_at) · the thing you wrote once - variants(id, post_id, network, body, media_ids_json, validated_at, validation_errors_json) · one row per network, because the caps and the allowed formats differ - schedules(id, variant_id, due_at_utc, author_timezone, state, claimed_by, claimed_at, attempts) · times stored in UTC with the authoring zone kept beside them - publish_attempts(id, schedule_id, attempt, request_id, http_status, remote_id, error_code, at) · every attempt including the ones that timed out - media(id, path, bytes, mime, width, height, duration_ms, checksum) · checksummed so one file is uploaded once per network and reused - network_limits(network, max_chars, max_media, allowed_mime_json, posts_per_window, window_seconds, container_ttl_seconds, checked_on) · maintained by hand, with the date you last read the documentation - quota_usage(network, window_start, count) · what you have spent against the published cap in the current moving window - metrics(remote_id, network, collected_at, metric, value) · collected on a schedule, because nobody will give you last month's numbers later
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://feedhive@localhost/feedhive | Posts, schedules, attempts and metrics. |
MEDIA_DIR | required | ./data/media | Originals and cached renditions, keyed by checksum. |
WORKER_CLAIM_TTL_SECONDS | required | 300 | How long a claim survives a dead worker before the row is claimable again. |
LLM_API_KEYsecret | optional | sk-...redacted | Drafting per-network variants. The app works without it and you write the variants yourself. |
LLM_MONTHLY_CEILING | optional | 3.00 | Drafting stops at this figure for the month and manual editing continues. |
NETWORK_TOKENS_PATHsecret | required | ./data/tokens.json | Per-network OAuth tokens and refresh state. Never in the database dump you share. |
METRICS_INTERVAL_MINUTES | required | 240 | How often readings are appended. More often means more rows and a finer history. |
The build, in order
Variants that are checked before they are scheduled
A variant cannot enter the queue until it passes the limits recorded for its network.
With the date, because these numbers move and stale ones fail at publish time.
Files
migrations/002_network_limits.sqldocs/limits.mdString length is not what any of these networks measure.
Files
src/adapters/*/count.tstests/counting.test.tsValidation at publish time is validation that happens while you are asleep.
Files
src/schedule/create.tstests/validate-gate.test.ts
done when · tick each as it passeswatch out- Counting characters with the string length of your runtime will pass a post that the network rejects, because some networks count code points, some count after shortening links, and emoji and accented characters are the ones that expose the difference. Implement the counting rule per adapter and unit test each one against a string of emoji, combining characters and a long link.
A worker that publishes exactly once
A due post is published once even when two workers run and the API times out.
The row count from the update is the lock.
Files
src/worker/claim.tstests/double-claim.test.tsSo an unknown outcome is investigable rather than guessed at.
Files
src/worker/publish.tsLook for the post before you make a second one.
Files
src/worker/reconcile.tstests/unknown-publish.test.ts
done when · tick each as it passeswatch out- Selecting due rows and then updating them is a race that publishes twice the first time two workers overlap, and the second copy has to be deleted by hand in front of an audience. Claim with a conditional update whose row count tells you whether you won, and treat a timed-out publish as unknown rather than failed, because retrying an unknown is exactly how the duplicate appears.
Living inside the published caps
The queue spends against each network's published cap deliberately rather than discovering it through errors.
The documented windows are moving, not calendar days.
Files
src/limits/window.tstests/sliding-window.test.tsA deferred post is recoverable; a rejected one needs a human.
Files
src/worker/claim.tsA container has a published lifetime and it is shorter than a weekend.
Files
src/adapters/instagram/container.ts
done when · tick each as it passeswatch out- Treating the cap as a daily reset at midnight will let a burst through at 00:01 and then fail the rest of the day, because the documented window is a moving one rather than a calendar day. Count attempts in a sliding window of the documented length, and separately expire any created container before the documented lifetime rather than publishing into a container that has already gone.
Media that survives the upload
A file is prepared once per network, uploaded once, and never silently re-encoded into something the network rejects.
The checksum is the identity of the file, not the filename.
Files
src/media/import.tsmigrations/003_media.sqlPrepared once, uploaded once, reused on retry.
Files
src/media/render.tstests/rendition-cache.test.tsRe-encoding from a rendition is how quality quietly dies.
Files
src/media/purge.ts
done when · tick each as it passeswatch out- Re-encoding on every publish attempt means a retry produces a byte-different file, a second upload, and two media identifiers for what the author thinks is one picture, which shows up as a duplicate in a carousel. Cache the rendition by source checksum and network, and make the upload step idempotent on that pair.
Numbers you can only collect forwards, and a way outproduct builder
Metrics are recorded from the first publish, everything exports, and a broken token degrades the app instead of stopping it.
Nobody will give you last month's numbers later.
Files
src/metrics/collect.tstests/metrics-append.test.tsOne dead token should not stop the other four queues.
Files
src/adapters/registry.tstests/degrade.test.tsAn export without the files is half an export.
Files
src/export/dump.tsscripts/reimport.sh
done when · tick each as it passeswatch out- Reading a post's metrics once and storing a single current value gives you no way to answer what happened in the first week, and no network will hand you that history later, so the question is unanswerable forever. Append every reading with its collection time, and accept that the table grows, because it is the only historical record that will ever exist.
That is the whole plan for FeedHive. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Anything driving a logged-in browser session.
- Automatic engagement and comment generation.
- Team approvals and shared asset libraries.
- Backfilled analytics from before collection started.
- analytics history and attribution
- team approvals and asset workflows
- protection from API and policy changes
- calendar-provider edge cases and timezone correctness
- reliable direct publishing across networks
- Recycling, implemented as an evergreen post returning to the queue as a draft that you approve again rather than as an automatic repost
- A limits drift checker that flags any network_limits row older than your chosen cadence
- A per-network dry run that renders exactly what would be sent, which catches format mistakes before they cost a cap slot
Need the files? The project pack on the verdict page hands your agent the whole brief · more social media.