s4l.ai / guide

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.

M
Matthew Diakonov
10 min read
4.9from 27
8 engagement styles ranked by live avg_upvotes
MIN_SAMPLE_SIZE = 5 before the scheduler trusts a style
Per-platform hard excludes via PLATFORM_POLICY
Source of truth: scripts/engagement_styles.py:162
MIN_SAMPLE_SIZE = 5 before the scheduler trusts a style8 engagement styles live in STYLES dictReddit excludes curious_probe (PLATFORM_POLICY)LinkedIn and GitHub exclude snarky_onelinerAVG(upvotes) GROUP BY engagement_style WHERE platform = %strusted styles sorted by avg_up DESCtop third -> dominant tier, bottom third -> rare tiercold start returns ([], all, []) so every style gets sampledrecommendation style only on reply pipelines39 launchd plists keep the loop ticking

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.

scripts/engagement_styles.py

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.

scripts/engagement_styles.py

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

COUNT(*)
AVG(upvotes)
PLATFORM_POLICY[platform].never
MIN_SAMPLE_SIZE = 5
get_dynamic_tiers
dominant
secondary
rare

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.

FeatureGeneric marketing automationS4L
How the tool picks tone for the next postA '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 underperformingNothing. 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 styleNo 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 excludesOne 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' livesA 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 signalClick-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 writerIt 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.

scripts/engagement_styles.py

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

1

Launchd fires

engage plist at 01:00

2

Query posts table

SELECT avg_upvotes GROUP BY style

3

Apply PLATFORM_POLICY

drop never styles

4

Split trusted into thirds

dominant / secondary / rare

5

Render prompt block

## Engagement styles

6

LLM drafts + posts

engagement_style tagged in DB

What runs on a weekday for a single platform

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

0engagement styles in STYLES dict
0samples before a style is trusted
0launchd plists in /launchd
0engage cycles per platform per day

the one constant that gates every style

0minimum samples before a style is trusted

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.

ln(posts)

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.