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.
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.
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
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.
The numbers that govern the picker
These are the literal constants and comments inside engagement_styles.py. Nothing inferred, nothing marketing-rounded.
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.
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)
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.
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.
| Feature | RecurPost-category scheduler | S4L |
|---|---|---|
| Primary output | A 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 selection | Not 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 input | Analytics 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 behavior | Indistinguishable 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 policy | Per-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 guard | Not 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 mix | Unbounded (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 line | Recycling 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.
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
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.
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).
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.
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.
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.
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.
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.
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.
Other pieces of the same pipeline, each anchored to a specific file
Related guides
Social media automation vs RecurPost: the scheduler that refuses to recycle
The other half of the same argument: per-subreddit cooldown floors (1 day own, 3 day external) and a lifetime thread URL dedup set.
Best social media automation tools: one Claude session per reply
Why S4L shells out to a fresh claude -p subprocess per reply instead of sharing LLM context across a run.
Social media auto posting that waits 5 minutes before deciding
The T0/T1 velocity loop. Snapshot engagement, sleep 300 seconds, snapshot again, rank by delta_score.