Marketing automation for social media that picks its own voice before every post.
Every other marketing automation tool on this SERP treats brand voice as a preset you pick during onboarding. S4L treats it as a variable. It keeps 8 engagement styles in a Python dict, tags every post with the one it used, and re-sorts them from the posts table before it writes the next draft. The SELECT query, the 5-sample threshold, and the per-platform excludes are all in one file. Below is what each of them actually does.
the uncopyable bit
Every run, S4L asks Postgres one question: which style has the highest avg(upvotes) on this platform?
Then it splits the answer into thirds. The top third becomes the dominant block in the next LLM prompt. The bottom third becomes the rare block, still allowed but rarely picked. Everything under 5 samples goes to explore so the loop never stops sampling new styles. The query and the split live together in scripts/engagement_styles.py between lines 132 and 211.
The one query that drives the whole picker
Everything else in this file is a sort on top of this. The WHERE clause is strict on purpose: posts must be active, must have a style tag, must have real content (30+ chars), and must have an upvote count pulled by the stats refresh job.
The 8 styles the ranker picks from
These are not generic labels. Each one carries a description, an example sentence, a list of subreddits or topics where it has historically worked, and a hard rule that survives any ranking. Read them as a taxonomy, not a vibe.
critic
Point out what is missing, flawed, or naive, then reframe the problem. Strong on r/Entrepreneur and r/startups. Never just nitpick; the rule in STYLES requires a non-obvious insight before the style is picked at all.
storyteller
Pure first-person narrative with specific details: numbers, dates, names. Leads with failure or surprise, never with success. The STYLES note forbids pivoting to a product pitch mid-story.
pattern_recognizer
Name the pattern. Authority through pattern recognition, not credentials. Tuned for r/ExperiencedDevs and r/programming, where credential drops get downvoted but pattern names get upvoted.
curious_probe
One specific follow-up question about the most interesting detail. Hard-coded as never on Reddit in PLATFORM_POLICY because probe questions read as lurking, but allowed on niche B2B threads on Twitter and LinkedIn.
contrarian
Take a clear opposing position backed by experience. The STYLES note requires credible evidence; empty hot takes get removed before they reach the prompt. Tuned for industry-debate threads.
data_point_drop
Share one specific, believable metric and let the number do the talking. STYLES rule: no links, and numbers must be believable rather than impressive. '$12k in a month' beats 'a lot of money'.
snarky_oneliner
One sentence that validates a shared frustration. Allowed on large Reddit subs and viral Twitter threads. Hard-excluded on LinkedIn and GitHub via PLATFORM_POLICY[platform].never. Never runs in r/vipassana or other small serious subs.
recommendation
Only surfaces on reply pipelines. REPLY_STYLES = VALID_STYLES | {recommendation}, so new-post pipelines never see it. Tier 1/2/3 link strategy layered on top by check_link_rules.py.
The tiering function, verbatim
This is the function called before every Reddit post, every Twitter reply, every LinkedIn draft. The split into thirds is deliberately coarse. Fine-grained bandit math breaks when the data is sparse; a third-of-a-third-of-8 split is robust even at cold start.
Inputs, hub, outputs
Four things flow into get_dynamic_tiers. One thing comes out: a sorted, platform-aware split that drops straight into the LLM prompt.
posts.engagement_style + platform policy -> tier split
Why a brand-voice preset is not marketing automation
The other nine results on this SERP describe marketing automation as schedule + AI content + dashboard. What that misses is the feedback step. If the dashboard shows a tone underperforming but the next scheduled post is drafted in the same tone, nothing automated actually happened. S4L closes that specific loop.
| Feature | Generic marketing automation | S4L |
|---|---|---|
| How the tool picks tone for the next post | A 'brand voice' preset chosen during onboarding. Same tone for every post, every platform. | get_dynamic_tiers() re-queries Postgres before every draft. Same account can run critic on Twitter and storyteller on Reddit in the same hour because each platform has its own tier split. |
| What happens when a style starts underperforming | Nothing. Unless you manually edit the brand voice, the tool keeps producing the same style. | That style's avg_up drops in the posts table. Next get_dynamic_tiers() call pushes it out of the top third into secondary, then into rare, without any human action. |
| Cold start (no history yet) | Falls back to a generic 'friendly professional' default, same across every customer. | Returns ([], all_styles, []) on line 198 when _fetch_style_stats() comes back empty. Every allowed style enters as 'secondary' so every style gets sampled before any gets trusted. |
| Minimum sample size before trusting a style | No explicit threshold; the tool ships averages from the first click. | MIN_SAMPLE_SIZE = 5 on line 129. Any style with fewer than 5 logged posts for this platform skips the trusted list entirely and lands in explore. |
| Per-platform hard excludes | One toggle for 'casual vs professional'. No way to say 'never this style on LinkedIn'. | PLATFORM_POLICY[platform]['never']. Static. Reddit hides curious_probe, LinkedIn and GitHub hide snarky_oneliner. A style on the never list cannot be promoted even if its avg_upvotes is the highest in the table. |
| Where the 'voice' lives | A tenant-level setting in the SaaS app. Opaque to the user. | posts.engagement_style is a plain column. Every published post carries the style tag. `SELECT engagement_style, AVG(upvotes) FROM posts GROUP BY 1` is a one-line audit query. |
| Feedback signal | Click-through rate on scheduled posts, read inside the tool's dashboard. | Raw upvote count per post, pulled from the live platform after the posting browser closes. Stats refresh runs on its own launchd plist: com.m13v.social-stats-reddit.plist. |
| How the ranking reaches the writer | It doesn't. The LLM sees the brand voice string and produces in that voice. | The tier split is rendered into a ## Engagement styles block at the top of the LLM prompt, grouping styles as dominant / secondary / rare, with the platform-specific note on top. |
The static policy that survives every ranking
Not every decision should be empirical. Some styles are wrong for a platform the way hats are wrong indoors: it is a culture fact, not a performance fact. PLATFORM_POLICY is where those facts live. A style here cannot be promoted into dominant no matter what avg_upvotes says.
What the policy actually excludes
- Reddit hides curious_probe, full stop. Reads as lurking on Reddit even when avg_upvotes says otherwise.
- LinkedIn hides snarky_oneliner. Too blunt for the feed culture, so it cannot be promoted into dominant.
- GitHub hides snarky_oneliner too. Technical specificity wins; snark invites the opposite vote.
- Reddit note pins prompt with: 'Short wins. 1 punchy sentence or 4-5 of real substance.'
- LinkedIn note pins prompt with: 'Professional but human. Softer critic framing.'
- Twitter lets every style play. Direct product mentions allowed there, not allowed on Reddit.
The full cycle, in 6 steps
One launchd plist runs the whole sequence inside a single browser lock. A stats refresh plist runs separately and keeps the upvote column current so the next cycle's ranking reflects reality.
engage plist -> stats query -> tier split -> prompt -> post -> log
Launchd fires
engage plist at 01:00
Query posts table
SELECT avg_upvotes GROUP BY style
Apply PLATFORM_POLICY
drop never styles
Split trusted into thirds
dominant / secondary / rare
Render prompt block
## Engagement styles
LLM drafts + posts
engagement_style tagged in DB
What runs on a weekday for a single platform
01:00 launchd fires the engage plist
StartCalendarInterval kicks com.m13v.social-engage.plist. The shell acquires a per-platform browser lock so a scan cannot collide with a post.
Postgres returns engagement_style stats
_fetch_style_stats(platform) opens a short-lived connection, runs the SELECT, returns {style: {n, avg_up}}. On DB error it returns {} and the picker falls back to cold start.
PLATFORM_POLICY drops excluded styles
Reddit removes curious_probe. LinkedIn and GitHub remove snarky_oneliner. The remaining candidate_styles list is what the tier function operates on.
Tier split by avg_upvotes
Trusted styles (n >= 5) sort by avg_up DESC. Top third is dominant, bottom third is rare, middle is secondary. Styles under 5 samples get appended to secondary so every allowed style is still reachable.
Prompt renders the tier block
get_styles_prompt prints '## Engagement styles' and under it the dominant / secondary / rare sections with each style's description and its best-in topics for this platform.
Claude drafts, browser posts, row logs
After the reply lands, insert into posts (platform, our_content, thread_url, engagement_style, posted_at). A later stats plist sweeps upvotes. Next cycle's ranking sees this row immediately.
the one constant that gates every style
MIN_SAMPLE_SIZE sits on line 129 of engagement_styles.py as a plain Python integer. Lower and one lucky viral post would promote a mediocre style into dominant. Higher and newer styles could never graduate during the window where novelty still lifts engagement. 5 is the knee of the curve.
“engagement_style lives in a plain posts column. A one-line SQL query on your own database audits which voice actually won for your account, per platform, per month.”
scripts/engagement_styles.py + schema-postgres.sql
See the whole loop in action
S4L runs the style picker, the engagement scan, the T0/T1 reply scorer, and the Phase D link editor on one Mac. Pricing covers the full stack, no per-platform add-ons.
See pricing →Frequently asked questions
What does 'marketing automation social media' mean inside S4L?
Three things, not one. Outbound posting (scheduled through launchd, one plist per platform). Inbound engagement (scan replies, score candidates, draft replies). And a feedback loop that ties the two together: every post and every reply is tagged with an engagement_style, the upvote outcome is written back to Postgres, and the next run re-ranks styles before it writes anything. Most tools on this SERP ship the first thing. S4L's automation is really the third thing.
Where is the re-ranking logic in the code?
scripts/engagement_styles.py, lines 132 through 211. The entry point is get_dynamic_tiers(platform, context). It calls _fetch_style_stats(platform), which runs SELECT engagement_style, COUNT(*), AVG(upvotes) FROM posts WHERE status = 'active' AND platform = %s GROUP BY engagement_style. Styles with COUNT < MIN_SAMPLE_SIZE (5) are dropped into 'explore'. Trusted styles are sorted by avg_up DESC and split into dominant (top third), secondary (middle), and rare (bottom third). The result is rendered into the LLM prompt at line 216+.
Why MIN_SAMPLE_SIZE = 5 and not something higher?
5 is the smallest number where a standard deviation starts to mean anything for upvote counts that span two orders of magnitude. Below 5, one lucky viral post would promote an otherwise weak style into dominant. Above 10 or 15, a brand new style could never graduate into 'trusted' during the window where engagement is still novel. Line 129 of engagement_styles.py has the constant. It is intentionally low because cold-start velocity matters more than statistical perfection.
What is the difference between never and rare?
'Never' is editorial. A style on PLATFORM_POLICY[platform]['never'] is forbidden on that platform no matter what the posts table says. Reddit has curious_probe on never because probe questions get read as lurking, and that doesn't change. 'Rare' is empirical. A style in rare did show up in the data but ranks in the bottom third of trusted styles for this platform. Rare styles still get written occasionally so the loop keeps exploring; never styles never run at all.
How does the scheduler actually see the ranking?
get_styles_prompt(platform, context) runs after get_dynamic_tiers() and builds a multi-line string: ## Engagement styles, then a dominant block, a secondary block, and a rare block. Each block lists the style names, their one-line descriptions from STYLES, and the 'Best in' subreddits/topics for this platform. The whole string is injected into the Claude prompt before the thread context. Claude doesn't pick blindly; it picks from a list sorted by this account's measured lift.
What happens to styles that run well on one platform but badly on another?
They split. Because _fetch_style_stats() filters by platform, snarky_oneliner can sit in dominant on Twitter (where terse lands) and in rare on r/vipassana (where it reads as dismissive). The tier split is literally per-platform; there is no global style ranking. This is the thing that most marketing automation tools fail at: their 'brand voice' is a single slider, not a per-surface distribution.
How often does this run in production?
The engage plist fires at 01:00, 05:00, 09:00, 13:00, 17:00, and 21:00, six times a day. Each fire runs the full flow: scan, rank, pick, write, post, log. The posts table gets a new row on each post with engagement_style set. A separate stats plist (com.m13v.social-stats-reddit.plist and its siblings) refreshes the upvote column on old rows so the ranking stays current. 39 launchd plists keep the loop ticking.
Can I see which style won for my own account?
One SQL line: SELECT engagement_style, COUNT(*), AVG(upvotes) FROM posts WHERE platform = 'reddit' GROUP BY engagement_style ORDER BY 3 DESC. That is exactly the query S4L runs before every draft. If a style isn't listed, either it has been 'never' for this platform, or it hasn't hit MIN_SAMPLE_SIZE yet. scripts/top_performers.py wraps this query in a pretty-printed report if the CLI is more your thing.
Related guides
Social media auto posting that waits 5 minutes before it decides
After the scheduler picks a style, how does it pick a thread? A T0 snapshot, a 300-second sleep, and a delta weighted by retweets 3x, replies 2x, views over 1000.
Marketing automation and social media with measured engagement lift
The reply side of the same loop: twitter_candidates rows keep likes_t0 and likes_t1 in the same row, so every reply has a before-and-after lift number.
Social media automation tool, end to end
Scan, dedup, style pick, reply, self-reply, stats refresh, and the Phase D link edit sweep that comes after proven posts.