M
Matthew Diakonov
13 min read
Social media automation tools vs RecurPost

RecurPost schedules. S4L picks the tone of every reply.

The top results for this keyword describe the same product shape: content libraries, calendar slots, evergreen recycling, bulk upload. None of them talk about the actual language of the copy the tool emits. S4L does. Before a reply is drafted, scripts/engagement_styles.py picks from a catalog of 8 named engagement styles whose priority is re-ranked on every single run by a SQL query against the posts table. This page walks through the file, the query, the tier split, and the policy overlay that sits on top of it.

5.0from code-verified against scripts/engagement_styles.py
8 named styles
60 / 30 / 10 tier split
SELECT AVG(upvotes) per run
MIN_SAMPLE_SIZE = 5
Policy overlay per platform

What the RecurPost category actually does

Starting point, no strawman. RecurPost and the tools that live on its comparison pages (Buffer, Hootsuite, SocialBee, Sprout Social, Agorapulse, Later, Publer) share a product shape: a scheduler on top of a content library, with analytics, an inbox, team seats, and a recycling cadence per library. You write the copy, you drop it into a bucket, the tool takes it from there.

content librariesevergreen recyclingposting calendardrag-and-drop time slotscadence per libraryunified inboxRSS auto-importIG DM automationbulk upload CSV

The thing every page in the top 10 results skips: none of these tools touches the tone of what you post. Style is your job. The tool queues and ships. That is the unexploited space S4L operates in, so the rest of this page is about the specific mechanism.

The style picker in one motion sequence

Five frames. Roughly thirteen seconds. This is what fires at the start of every run, before anything touches a browser or the LLM.

Anatomy of a single pick

01 / 05

frame 1

Cron fires. The run script shells into Python first. engagement_styles.py is imported before any browser or LLM call.

The 8 styles, by name

This is not a vibes-based “brand voice” selector. It is an enum. Each entry has a description, an example, and a note with a rule. The model sees these labels verbatim in the prompt block.

scripts/engagement_styles.py
criticstorytellerpattern_recognizercurious_probecontrariandata_point_dropsnarky_onelinerrecommendation (reply-only, MAX 20%)

The numbers that govern the picker

These are the literal constants and comments inside engagement_styles.py. Nothing inferred, nothing marketing-rounded.

0Named engagement styles
0%Primary tier use rate
0Min sample size before tiering
0%Max recommendation-style replies

Verification: open scripts/engagement_styles.py and read 0 (MIN_SAMPLE_SIZE), 0 (60% directive), and 0 (recommendation 20% cap) as line anchors. The dict starts at line 0.

The anchor: a query, a sort, a split

If the argument of this page compresses to one thing, it is this function. The query at the top of it is what makes every claim on this page cheap to verify: open the file, read the SQL, open psql, run the SQL, see the tier membership for yourself.

scripts/engagement_styles.py

The one sentence that inverts the RecurPost model

“Tier assignment is DB-driven, not hardcoded.”

That is the top of the comment above PLATFORM_POLICY at line 96. The whole scheduling category treats tone as a user-owned artifact (you wrote it, tool queues it). This one function treats tone as a decision the tool makes every run, backed by the row-level receipts from the last run. It is the same product surface, read backwards.

Inputs to the tier sort, outputs to the prompt

Four inputs feed the tiering function. Three outputs leave it, each a distinct usage directive in the emitted prompt block.

get_dynamic_tiers(platform, context)

posts table (Postgres)
PLATFORM_POLICY.never
MIN_SAMPLE_SIZE = 5
context: posting or replying
get_dynamic_tiers()
PRIMARY styles
SECONDARY styles
RARE styles

Inside the four tier buckets

There are three tiers for the sort (dominant, secondary, rare) and a fourth for policy (never). The never tier is applied after the sort, not before it, which matters when a style is a top performer on one platform but inappropriate on another.

dominant (primary)

Top third of trusted styles on this platform, by average upvotes from past posts. The generated prompt tells the model to reach for these roughly 60% of the time. Membership changes between runs: a style that under-performs drops out automatically.

secondary (explore)

Middle third of trusted styles, plus every style with fewer than MIN_SAMPLE_SIZE=5 logged posts on this platform. This is how new styles (and new platforms) get exposure without being forced into the primary slot before the data warrants it.

rare

Bottom third of trusted styles. Still allowed, still suggested, still sampled at roughly 10%, so the ranking is not a one-way trapdoor. If upvotes recover, the style moves back up on a later run.

never (policy-level)

Hard-coded per platform. No snarky_oneliner on LinkedIn. No curious_probe on Reddit. These overrides run after the tier sort, so even a high-upvote style gets excluded if the platform-level policy forbids it.

The policy overlay

Performance data is not the only signal. Brand policy sits on top, and it removes styles that would be wrong for the surface regardless of how well they score. This is the code for that overlay.

scripts/engagement_styles.py

Side by side: RecurPost category vs S4L

Per-row: what the scheduling category does, what S4L does, at the specific mechanism level, not the brochure level.

FeatureRecurPost-category schedulerS4L
Primary outputA post that ships on a calendar slot, optionally drawn from an evergreen content library on a recycling cadence.A reply whose tone has been chosen from a named 8-style catalog before the model sees the prompt.
Tone selectionNot part of the tool. You write the copy, the tool schedules it.scripts/engagement_styles.py defines 8 styles and emits a prompt block tagged PRIMARY / SECONDARY / RARE, with platform-specific never lists.
Ranking inputAnalytics dashboard. Surfaced to you, not fed back into the tool.SELECT engagement_style, COUNT(*), AVG(upvotes) FROM posts GROUP BY engagement_style. Runs every pick. Feeds the tier split directly.
Cold start behaviorIndistinguishable from a warm start. The calendar fires either way.get_dynamic_tiers returns ([], explore, []). Everything goes into secondary until MIN_SAMPLE_SIZE=5 posts per style have landed, so the first runs explore uniformly.
Per-platform brand policyPer-post. You decide what to write for LinkedIn vs X when you queue the copy.PLATFORM_POLICY dict. LinkedIn drops snarky_oneliner. Reddit drops curious_probe. GitHub forbids snark and enforces 400-600 char length. The policy is applied on top of the upvote sort.
Anti-validator guardNot a concept.Every generated prompt ends with: “AVOID the ‘pleaser/validator’ style ('this is great', 'had similar results', '100% agree'). It consistently gets the lowest engagement across all platforms.”
Self-promo mixUnbounded (whatever you put in the library).The recommendation style is tagged MAX 20% of replies, hard-coded in the prompt emitter at line 280 of engagement_styles.py.
Where the tool draws the lineRecycling and scheduling. Tone, selection, fit to thread = your job.Scheduling is minimal. The work the tool does is picking the style, picking the thread, writing the reply, then recording which style was used so the next run can re-rank.

What the model actually reads

The tier split is not an invisible input. It is written verbatim into the prompt block that the child Claude process receives. This is the emitter, and below it is the output.

scripts/engagement_styles.py
engagement_styles.get_styles_prompt('reddit', 'replying')

End to end: from cron to row insert

How the tier sort stitches into the rest of the pipeline. Everything upstream is scheduling, everything downstream is generation and logging.

One reply, start to finish

launchdrun-reddit.shengagement_styles.pyposts (Postgres)claude -pfire cron (every 30m)get_styles_prompt('reddit', 'replying')SELECT AVG(upvotes) GROUP BY engagement_stylerows -> {style: n, avg_up}drop PLATFORM_POLICY.never stylessplit trusted into dominant/secondary/rarestyles prompt with 60/30/10 tier labelsclaude -p <prompt with tiered styles>INSERT posts (engagement_style=X, upvotes=NULL)

The seven steps between a cron tick and a posted reply

Not generic architecture. These are the named function calls in the order they run.

1

Cron kicks the pipeline

launchd fires run-reddit.sh every 30 minutes. The shell wrapper sources .env and invokes the Python helpers first (no browser, no LLM yet).

2

get_styles_prompt() runs the SQL

engagement_styles.get_dynamic_tiers(platform) runs the AVG(upvotes) query against the live posts table. Cold start returns everything as secondary.

3

Policy overlay strips forbidden styles

PLATFORM_POLICY[platform].never is subtracted from the candidate set. snarky_oneliner is removed on LinkedIn. curious_probe is removed on Reddit.

4

Trusted styles split into thirds

Any style with >= MIN_SAMPLE_SIZE (5) logged posts is trusted. The trusted list sorts by avg_upvotes DESC and splits: top third dominant, middle secondary, bottom third rare.

5

Prompt block emits with tier labels

get_styles_prompt returns a markdown-shaped string with PRIMARY / SECONDARY / RARE headings and a 60/30/10 usage directive. Recommendation style is appended only in reply context.

6

claude -p writes the reply

The child Claude process receives this prompt block embedded in the run prompt. It picks a style, writes the reply, posts it, and records engagement_style on the row it inserts into posts.

7

Next run re-ranks

30 minutes later the loop fires again. The SELECT now sees one more post. Over time, styles that under-perform on this platform drop tiers. The tool learns per platform, per week.

Four things the picker does that the RecurPost category cannot

Every card here maps to a specific block of code. No generic claims.

The tier sort is per-platform, not global

avg_upvotes on Reddit and avg_likes-as-upvotes on Twitter are computed in separate queries, stored on separate rows, sorted separately. storyteller might be primary on Reddit and rare on LinkedIn on the same day.

It re-runs every invocation

No cron-day cache. No nightly rebuild. The SELECT fires inside get_dynamic_tiers at the start of every pick, so a post that lands ten minutes ago is already influencing the next pick.

Cold start is explicit

If no posts on this platform clear MIN_SAMPLE_SIZE, dominant is empty, rare is empty, and every style goes into secondary. The prompt still ships; exploration is the intended behavior.

The bottom of the prompt blocks validators

Every generated styles prompt ends with an explicit AVOID clause: 'pleaser/validator' replies (“this is great”, “had similar results”, “100% agree”) are the lowest-performers and are cut before the model sees the thread.

When RecurPost is the right tool

If the work is “I wrote 80 evergreen social posts, cycle them across five channels on a cadence, keep the inbox unified,” RecurPost is shaped for that. S4L is not. S4L has no content library, no drag-and-drop calendar, no IG DM automation, no bulk CSV importer. If the work is “I want to reply inside threads that match my projects, in a tone that keeps improving per platform week over week,” that is the shape of S4L. The two products are adjacent in SERPs and not adjacent in product design. Knowing which work you are trying to automate is the decision.

Walk through the engagement_styles.py file with me

15 minutes. I will show you the SQL, the tier split, and the config in a live run against a real Neon DB.

Book a call

Frequently asked questions

How is this different from RecurPost's scheduling?

RecurPost's unit of work is a scheduled post drawn from a content library; the tool queues copy you wrote and fires it on a calendar cadence. S4L's unit of work is a reply to somebody else's thread, and the tone of that reply is chosen by a tier-ranked style picker before the prompt is assembled. Concretely: scripts/engagement_styles.py defines eight named styles (critic, storyteller, pattern_recognizer, curious_probe, contrarian, data_point_drop, snarky_oneliner, recommendation) and runs a Postgres query on every invocation to rank them by average upvotes per platform. Nothing in the RecurPost listicle category does this, because their primitive is the scheduled post, not the style-chosen reply.

Where exactly is the tiering implemented?

scripts/engagement_styles.py, function get_dynamic_tiers(platform, context='posting') at lines 162-211. It calls _fetch_style_stats, which runs `SELECT engagement_style, COUNT(*) AS n, AVG(COALESCE(upvotes,0))::float AS avg_up FROM posts WHERE status='active' AND engagement_style IS NOT NULL AND our_content IS NOT NULL AND LENGTH(our_content) >= 30 AND upvotes IS NOT NULL AND platform = %s GROUP BY engagement_style`. Trusted styles (N >= MIN_SAMPLE_SIZE = 5) are sorted by avg_up DESC and split into thirds: top -> dominant, middle -> secondary, bottom -> rare. Anything below the sample threshold (including zero-sample styles) goes to secondary so the model still explores it.

What are the 8 engagement styles?

Seven are posting styles, shared across all platforms: critic (point out what's missing or naive, reframe), storyteller (first-person narrative with specific details, lead with failure not success), pattern_recognizer (name the phenomenon, authority through pattern recognition), curious_probe (one specific follow-up question with a 'curious because' clause), contrarian (opposing position backed by experience), data_point_drop (one specific believable metric, numbers must be believable not impressive), and snarky_oneliner (one punchy sentence, NEVER on LinkedIn). The eighth, recommendation, only appears in reply context and is capped at MAX 20% of replies.

How is the 60/30/10 split enforced?

It is not a hard selector; it is a directive in the generated prompt. get_styles_prompt emits three headings (PRIMARY, SECONDARY, RARE) with the instruction 'use these ~60% of the time', '~30%', '~10%'. The LLM picks inside those tiers. The empirical split therefore depends on how well the model follows the instruction, which is why the styles list keeps being ranked by live upvotes rather than a fixed mix: any style the model over-fires that under-performs will drop a tier on the next run.

What is MIN_SAMPLE_SIZE and why 5?

MIN_SAMPLE_SIZE = 5 is the cutoff between 'trusted' and 'explore' styles. A style with fewer than 5 logged posts on this platform is not trustworthy enough to rank on average upvotes, so it goes to secondary (explore) regardless of the noisy avg. Five is low enough that a new platform warms up in a week or two of daily posts, high enough to damp out the first lucky run that lands 400 upvotes on a style that is otherwise a dud.

What if the posts table is empty (cold start)?

_fetch_style_stats returns {} and get_dynamic_tiers returns ([], explore, []) where explore = every non-never style. The prompt block ships with no PRIMARY section, no RARE section, and one SECONDARY heading listing every style. The 60/30/10 language is omitted. The model gets told to explore uniformly. Once MIN_SAMPLE_SIZE posts with non-null upvotes land per style, tiering begins to kick in automatically. This is why the first week of S4L's output looks visibly more varied than a tool with a fixed prompt.

Why is snarky_oneliner allowed at all?

Because it performs well on the platforms where it is allowed (large Twitter threads, 500k+ member subreddits). PLATFORM_POLICY drops it for LinkedIn and GitHub because the tone is wrong for professional surfaces, regardless of what the upvote data says. Two separate constraints: tone (never list) and performance (tier sort). They compose; the never list runs after the tier sort, so a style can be top-ranked and still blocked by policy.

What is the recommendation style and why is it capped?

recommendation is the only reply-specific style. It appears only when get_styles_prompt is called with context='replying', and it has one job: casually mention a project from config.json. It's capped at MAX 20% of replies because casual project mentions are how social credibility is built on Reddit and LinkedIn, but self-promo past about one-in-five replies is the fastest way to get flagged as a promotional account. The cap is written directly into the generated prompt, not enforced in code, so the model reads it and moderates itself.

Does this work on Twitter, LinkedIn, GitHub too?

Yes. PLATFORM_POLICY defines entries for reddit, twitter, linkedin, github, moltbook. Each has its own 'never' list and its own 'note' (a tone/length hint inserted at the top of the styles prompt). The tier sort runs against the rows in posts that match WHERE platform = %s, so Twitter's dominant styles can be totally different from LinkedIn's on the same day. A style that never gets used on one platform stays in explore on that platform forever without affecting ranking elsewhere.

Why does the prompt end with the 'pleaser/validator' avoid line?

Because the data showed that replies along the lines of 'this is great', 'had similar results', '100% agree' consistently got the lowest engagement across all three major platforms, even when the surrounding thread was high-signal. The AVOID clause is a hard-coded guard at the end of every generated styles prompt. It's not one of the 8 styles because it isn't a style we want a 60/30/10 mix of; it's a class of output the tool actively refuses.

How does the recorded engagement_style feed back into the next run?

When a reply is posted, the row inserted into posts carries engagement_style = X, our_content, platform, status='active', and upvotes=NULL. Once the platform returns an upvote count (via the stats collection launchd job, scheduled at 00:45 UTC), that cell populates. From that moment on, the next get_dynamic_tiers call sees the new sample. No batch rebuild, no explicit retraining step. The ranking is just a GROUP BY AVG against whatever rows are in the table right now.

Is S4L a drop-in replacement for RecurPost?

No. It's not the same shape of tool. S4L doesn't have a calendar UI, a content library, bulk upload CSV, an RSS import, or IG DM automation. What it does have is a style-choosing reply pipeline with live upvote ranking, a per-subreddit cooldown floor (see our other guide on the 1 day / 3 day floors), and a set of macOS launchd jobs that run the loop unattended. If your workflow is 'queue evergreen copy, recycle on a cadence', RecurPost fits. If your workflow is 'reply in threads that match my projects, across Reddit, Twitter, LinkedIn, GitHub, and let the tool learn which tones work per platform', S4L fits.