a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
Focus lanes: music, pop culture, sports (the bio already says it: “music, pop culture, and sports served neat”). Goal: a compelling account that earns 100+ followers. Every number below is measured (live account + Axiom + the live DB), and every work item names its exact lever — file, function, env var, config page, or owner action.
user/info + last_tweets)| Fact | Value |
|---|---|
| Created | 2026-07-20 (9 days old) |
| Followers / following | 18 / 100 |
| Total posts | 1,204 (932 with media) |
| Tier | X Premium (subscription_type=Premium, 25k-char cap) |
| Pinned tweet | none |
| Views per tweet (last 20) | 0–20, median ~3 |
| Likes per tweet (last 20) | 0 (one tweet: 1 like) |
crosspost, delivered=posted)Per day, last 7: 178 · 135 · 105 · 135 · 128 · 135 · 138 (avg ~136/day).
Per surface (3d): sports_desk 77 · music_alert 62 · cinema_news 51 · pop_desk 45 · music_news 39 · music 32 · discourse 29 · market_alert 26 · bet_board 16(+32 skip) · market_drop 13 · music_desk 12 · cinema_desk 9.
tweet_posted, 3d)media 387 ok · upload 355 ok · tweet 52 ok · upload_video 48 ok · like 31 ok · quote 28 ok / 17 rejected (38% fail — the #1575 third-party 403) · reply 3 ok / 1 fail (own-thread only; replies to non-mentioners are tier-blocked, #1732).
x_game_round / x_game_answer)64 rounds opened in 7d (~9/day). 3 correct answers in 30 days.
settings rows, master guild 1482402399342825522)| Setting | Current value |
|---|---|
hours_start_et / hours_end_et |
0 / 23 — the bot posts 24/7 |
scheduler_firehose_surfaces |
9 surfaces on the every-30-min firehose: cinema_desk, cinema_news, curator, discourse, market_drop, music_desk, music_news, pop_desk, sports_desk |
scheduler_firehose_hourly_surfaces |
music (hourly) |
post_schedule (calendar) |
yaps: 12× market_drop + 11× x_game; chill: 6× x_game (17 game slots/day) |
x_crosspost_surfaces (X picker) |
music, music_news, discourse, market_drop, market_alert, cinema_desk, pop_desk, sports_desk, cinema_news (bet_* already off X) |
So the ~136/day isn’t a bug — it’s 9 firehosed surfaces × 24/7 working hours, mirrored to X, thinned only by each surface’s own self-gate/dedup.
≤ 25 X posts/day, budgeted per surface, every post the surface’s best:
| Surface (X) | Budget/day | Notes |
|---|---|---|
| pop_desk | 4 | breaking + biggest lanes |
| sports_desk | 4 | breaking + biggest lanes |
| music (drops + alerts + desk, one toggle group) | 5 | the alert stream is today’s 21/day — the budget forces best-of |
| music_news | 3 | |
| cinema_news | 3 | |
| discourse (trending reacts) | 2 | |
| x_game | 2 rounds (≈5 writes: 2 opens + boards/reveals) | 12:30 + 20:30 ET |
| trend beat (new, Phase 5) | 2 | 11:00 + 19:00 ET |
| Total | ≈ 23–25 writes/day | verified by the APL query in §10 |
market_drop + market_alert: off X entirely (Kalshi swings are room content). Discord rooms keep their current firehose untouched — the budgets live X-side only.
Follower ladder (dated): ≥35 by Aug 5 · ≥45 by Aug 12 · ≥70 by Aug 26 · 100+ by Sep 9. Each gate blocks the next phase until telemetry (§9) explains a miss.
0.1 X picker trim (/menu → experiments page → x_crosspost picker): uncheck
market_drop and market_alert. Saves ~13 posts/day immediately (39/3d measured).
0.2 Firehose off → packed calendar cycle (owner decision 2026-07-29). Clear
scheduler_firehose_surfaces + scheduler_firehose_hourly_surfaces and pack the
calendar grid instead. Recommended ratio over a 28-slot day (working hours
trimmed to 10:00–24:00 ET — overnight slots earn zero US engagement; if hours
stay 24/7, keep this same ratio in the daytime block and give overnight slots only
to the Discord-only surfaces):
| Surface | Slots/day | Placement notes |
|---|---|---|
| pop_desk | 4 | spread 11:00–22:00, one in 18:00–21:00 prime |
| sports_desk | 4 | one near lunch, two in the 19:00–23:00 game window |
| music (drops) | 3 | mid-morning, afternoon, evening |
| music_news | 3 | |
| music_desk | 2 | |
| cinema_news | 2 | |
| cinema_desk | 2 | |
| discourse (trending reacts) | 3 | |
| x_game | 2 | 12:30 + 20:30 ET (US lunch + prime) |
| curator (Discord-only on X picker) | 2 | |
| market_drop (off X) | 1 | |
| Total | 28 | X-mirrored ≈ 25 writes/day incl. game boards |
In ratio terms: pop : sports : music-group : cinema : discourse : game ≈
4 : 4 : 8 : 4 : 3 : 2, plus 3 Discord-only slots. The remaining X-side leak is the
event-driven music_alert stream (rides the music toggle, ~21/day measured) — cap
it via its tunable or let the Phase 1a budget close it.
0.3 Discord recruit (owner). One announcement in the master guild: follow @tootsiesbar, tap the notification bell, like/reply when you see her — first-hour engagement is what buys distribution. Target: +15–25 followers from the server by Aug 5. Optional: X game wins get a server shoutout.
0.4 Pin an intro tweet (owner, 5 min). In her voice: who she is, what the account serves, the Discord link. No pinned tweet exists today.
utils/x_crosspost.py — maybe_crosspost gains score: float | None = None
(each calling surface already holds its self-gate score; thread it through).score is not None and score < X_CROSSPOST_SCORE_FLOOR (default 0.75; room floor
stays 0.6, X takes the top band only); (2) per-surface daily budget — a DB server-rate
counter (the image_daily_cap pattern) keyed x:{surface_group}, defaults from the
§3 table via _X_DAILY_BUDGETS (env-overridable X_BUDGET_<SURFACE>); count only on
a successful post.crosspost gains delivered=skip, reason=budget|below_floor (both
reason values already fit the shared vocab; no new field names).tests/test_x_crosspost.py (floor skip, budget skip, budget resets
daily, score=None surfaces unaffected by the floor).The account’s API tier can’t reply to third-party tweets (#1732), but the owner can — and replies are the one discovery channel that grows a sub-100 account. So Toots does the drafting, the owner does the posting, and X stays visually unchanged:
cogs/x_reply_draft.py (+ pure core utils/x_reply_draft.py, unit-tested):
an on_message listener — whenever SHE posts a message in the master guild
that links an X status (wire desks, curator, music news — every tweet she already
sourced as engaging), it fetches the tweet (fxtwitter, keyless), drafts the reply
she’d leave under it (claude.draft_x_reply, Sonnet, purpose=x_reply_draft),
and posts the draft to #bot-logs — the owner’s one working queue — with the
draft in a copy-block + the tweet’s bare fixup link (Discord unfurls the
tweet inline under the draft; tapping it lands on the real x.com post to reply),
pinging the master user only (users-only allowed-mentions), tagged with the
room the content shipped in.docs/PROMPT_OPTIMIZATION.md;
measured with a golden-anchored register judge on real wire tweets
(scripts/dryrun_x_reply_voice.py): Sonnet 10/18 flagged → 2/18, Opus 2/18,
specifics intact. GPT additionally needs _GPT_VOICE (its ModelRules fork) and
still keeps a residual AI lean (12/18) — a Models-knob tradeoff for this
voice-heavy surface.inReplyToId) showed the owner edits 222/223 drafts and the edit
is one move — keep the first beat, cut the trailing clause (draft median 21
words → posted 12; 82 of 95 pure-cut posts cut the ENDING; pure-cut posts
out-viewed rewrites ~2.7×). Cause: two #1927 clauses (“take the clause”, “an
ordinary ending beats a quotable one”) read as live instructions once the
Models knob flipped this surface to Opus (Sonnet-era drafts ran 14w, Opus-era
22w). Fix per docs/PROMPT_OPTIMIZATION.md: cut both, reword SHORT → ONE BEAT
with a literal “about a dozen words” anchor — the anchor is the load-bearing
token (reword alone was a wash; anchored: 21w → 16.5w median, register flags
4/15 → 2/16 on real targets; a harder “ten words” anchor overshot back up).
The mirrored-clause + abstract-metaphor tells stayed — the owner’s edits
independently confirm them.utils/x_reply_draft.py:VOICE_EXEMPLARS, picked
by that week’s engagement, topic-diverse, sampled per call so the surface
can’t template on one line — the #1914 lesson; the two high-engagement
GPT-era lines that are the judge’s known-bad goldens are excluded by test).
Measured as a WASH on the register judge (ex 2/13 vs no-ex 5/26 flagged,
length 15w↔16w) — shipped anyway as drift insurance on other grounds
(measured voice > hand-written aspiration; the owner’s ongoing edit rate is
the real metric). The bigger finding was the JUDGE: it failed 4/17 of the
owner’s own engagement winners (“sneaker drop”, “festival lore” — CONCRETE
pop-culture metaphors read as the abstract-metaphor tell), so the judge was
drawing the line tighter than the owner’s revealed taste. Recalibrated:
three owner winners added as PASS anchors + the FAIL tell narrowed to
ABSTRACT/conceptual metaphors (“the image is a thing you can picture, not a
concept”). Validate-the-judge-before-acting-on-it, per
docs/PROMPT_OPTIMIZATION.md.x_reply_text, read through /debug/query) against the 285 replies the owner
hand-posted that week. The drafts had collapsed onto ONE sentence — the analyst
appositive, “[specific] is the [real headline / smart play / whole flex]” —
drafts vs owner: “the real/whole/actual/smart/better docs/PROMPT_OPTIMIZATION.md #9: contradicted → fail, couldn’t-verify →
not a fail.gather_context_beat, Grok X-crowd + Perplexity, framing="breaking") on the
tweet’s subject, so the reply rides or subverts the angle the conversation is
already on — context only (never recites the beat’s numbers as facts), fail-open
to a tweet-only draft when unprovisioned or the pull misses.X_REPLY_DRAFT_DAILY_CAP (100 — a backstop for a
bulk-op burst, not a throttle; the decline + dedup are the real pace), scoped
to an allowlist of the channels explicitly configured for the X-post surfaces
(the /menu channel sets — curator rooms and unwired channels are out by
construction; owner steer), her own tweets skipped, master kill switch, fully
fail-open. Zero X writes — read-only + Discord.x_reply_draft event (delivered room|skip, reason
dedup|daily_cap|fetch_failed|declined|send_failed|error).The tic residue: two dead ends, then a guard that works (owner, 2026-08-03, “these
idiosyncrasies in her prose are horrible”, with Discord searches showing 21 hits for
“flex” and walls of “the real X” / “nobody’s talking about”). The #1970 cut halved the
dominant frame (realnoun 28% → 11%); the residue then took three attempts, and the
sequence is the story (numbers in docs/PROMPT_OPTIMIZATION.md):
utils.x_reply_draft.tic_hits, trips 39% of live drafts vs 6% of the owner’s own
replies) plus ONE re-ask that names the offending phrase. Tics 9/36 → 2/36
(25% → 6%, p=0.046 — the owner’s own rate), register a wash, specifics intact, one
extra compose call only on the drafts that trip. The retry is taken only if it comes
back clean, so a degraded rewrite can never replace a good line. Telemetry:
x_reply_tic_guard (ok = the retry replaced the draft) — watch the ok rate.The general lesson, since it cost three rounds: “vary it, here’s everything you said” primes a verbal habit; “you wrote X, say it without X” removes it.
Banger lane (SHIPPED — owner steer 2026-08-03, “are we looking at replies / example
of a banger”, with a 1.1M-view quote-tweet as the example): the reply lane can only
ever draft under the code-owned wire accounts, because that is the only pool the desks
fetch. The owner’s own wins say the pool is wider: of her top 20 hand-posted replies,
four landed under accounts the wire list has never carried (@Rap, @DrewPavlou,
@THEUN1VRSE, @Kurrco), and all 20 sat under parents of 300K–17M views — the
11.7K-like winner (“Superhero fatigue was really just slop fatigue”) under a 17M-view
@FilmUpdates post with 862 replies. So the fourth trigger finds targets by heat, not
by account: each pop/sports/cinema_news slot takes search terms from its OWN top
stories (utils.banger_lane.search_terms, free and on-lane), runs ONE X
advanced_search (queryType=Top, min_faves + lang:en + -filter:replies +
since_time, all verified live), and drafts under what clears the floors
(pick_bangers: 3h window, view AND like floors, originals only, no piracy/farm spam,
ranked by engagement velocity so a climbing post beats a bigger stale one, one per
author). Everything downstream is the shared path — same produce_draft, same
per-tweet dedup, same daily cap, same kill switch, same EMPTY decline, same #bot-logs
hand-off — so the only new thing is discovery. Bounds are env
(X_BANGER_PER_SLOT=2 with 0 disabling the lane, X_BANGER_MIN_VIEWS=50K,
X_BANGER_MIN_LIKES=500). Telemetry: banger_lane (surface, count, kept) — a
sustained kept=0 with count>0 is the floors drifting past what the search returns,
the one failure here that raises nothing.
Reply lane (SHIPPED — owner steer after the reply data landed: 12 owner replies
out-viewed 48 originals ~13×, so more fresh targets, faster): the wire desks’
THIRD lane, living directly alongside biggest + breaking. Each pop_desk /
sports_desk / cinema_news slot, after the two posting lanes claim their moments,
kick_reply_lane drafts the freshest-hottest remaining story with an X link
(pick_reply_stories — the same order_for_breaking ranking, so “worth replying
to now” = “breaking-worthy now”) through the same draft path, tagged
reply · <desk> in #bot-logs. Rides the desks’ own slots + the wire fetch already
paid for (no new scheduler, no extra X reads); fire-and-forget so it never delays
the desk’s delivery; one shared per-tweet dedup + daily cap across both triggers
(X_REPLY_PER_SLOT=1 per desk slot).
to:tootsiesbar (the x_game webhook infra, #1497, already
polls ~60s). Dispatcher order in /x/webhook: a reply targeting an open game round →
the game (unchanged); anything else → the new cogs/x_mentions.py./ask brain (persona, constitution, grounding, abuse gates) →
XPoster.post_tweet(reply_to=...) — allowed on our tier because the author mentioned
her. Self-gate ≥0.6 like every surface.X_MENTIONS_DAILY_CAP=24 (≈1/hr), per-author cap 3/day, never reply to
bots or to her own account, conversation-level dedup.x_mention_reply event + ops-monitor line.3a. Quote-first breaking lane — REJECTED (owner steer 2026-07-29). Quotes
weren’t earning traction when on, and visually they read dead-similar to the tweet
they quote — a bad look. The owner replies manually instead, powered by the 1b
draft loop. (Kept here so the decision is on record; don’t re-propose.)
3b. In-lane follow-on-amplify. When a quote ships, follow the quoted account if in-lane, capped 8/day (durable counter). Follow-backs are real at this size; aggressive following is a suspension vector, so the cap is hard.
3c. Communities dry run. POST /2/tweets accepts community_id; write
scripts/dryrun_x_community.py, post one test into a large music Community. If the
tier 403s, the item dies; if it lands, wire community_id through
XPoster.post_tweet and give the x_game 12:30 round a Community home.
4a. “Take, not headline” X prompt pass. The desks’ compose prompts get an X-side nudge: lead with the verdict in her voice; a box score is garnish, never the lead. Measured by §9’s per-surface views.
4b. Weekly thread. One music/pop deep-dive thread per week (reply-to-self works; Premium longform distributes). Anniversary, chart story, sample-lineage.
4c. Polls. POST /2/tweets supports polls — “which album wins the week” / “who
takes tonight” — small XPoster addition, 2–3/week.
maybe_crosspost stores tweet_id → surface in a
kv namespace (x_tweet_surface, 14-day TTL) — the attribution the sweep needs.user/info (followers /
following) + last_tweets (~100 tweets) → emit x_audience (followers,
following, tweets_24h, views_median, views_sum, likes_sum, engagement_rate) + one
x_surface_perf row per surface (views_median, posts). Fields registered in
utils/event_schema.py.x_stalled (7-day follower
delta < +5 with ≥50 posts) and x_engagement_zero (median views < 25 over ≥50
posts). Deterministic core unit-tested like every finding.crosspost surface="trend_beat".# posts/day (acceptance for Phase 0/1: ≤25)
['tootsies'] | where event == 'crosspost' and delivered == 'posted' | summarize count() by bin(_time, 1d)
# per-surface budget adherence + skip reasons
['tootsies'] | where event == 'crosspost' | summarize count() by surface, delivered, reason
# mention-responder volume
['tootsies'] | where event == 'x_mention_reply' | summarize count() by bin(_time, 1d)
# audience curve (once x_audience ships)
['tootsies'] | where event == 'x_audience' | project _time, followers, views_median, engagement_rate
utils/x_crosspost.py has_link); cutting ~110 posts/day cuts the X bill roughly
5×, and the twitterapi.io credit burn with it. The reply lane + sweep add only reads
(memoized wire fetches + 1 trends call + ~6 last_tweets pages/day).x_crosspost.strip_market_links,
owner steer). This covers BOTH venues, Kalshi and Polymarket, since a market surface
falls back to Polymarket when Kalshi has no market. The market card on Discord keeps its
market button. The tweet drops the exchange link and posts the take plus the chart
image. Two reasons, in order:
market_alert, market_drop, bet_board, bet_alert,
cinema_desk) posted 482 of the 2104 crossposts in the last 21 days, or 23%. Over
the same window 1189 crossposts billed at the link price. A market post keeps
has_link=True only if it still carries a non-exchange link, such as a cinema_desk
TMDB link.The host list is _MARKET_LINK_HOSTS (kalshi.com, polymarket.com). Sportsbook links
on a bet_alert card are NOT covered — the rule is about the prediction-market
exchanges.
| # | Item | Phase | Effort | Type |
|---|---|---|---|---|
| 1 | X picker: market_drop + market_alert off | 0.1 | 2 min | /menu config |
| 2 | Firehose off → packed calendar per the §4 ratio | 0.2 | 15 min | /menu config (owner) |
| 3 | Discord follow campaign + pinned intro tweet | 0.3–0.4 | 30 min | owner |
| 4 | Per-surface X budgets + 0.75 score floor | 1a | S–M | code (utils/x_crosspost.py) |
| 5 | Reply drafts under every linked tweet — SHIPPED | 1b | — | cogs/x_reply_draft.py |
| 6 | X mentions responder (standing rule → /ask → in-thread) | 2 | L | code (cogs/x_mentions.py) |
| 7 | 3a | — | rejected (owner steer) | |
| 8 | Follow-on-amplify, 8/day cap | 3b | S | code |
| 9 | Communities dry run → community_id |
3c | S | script, then S |
| 10 | “Take, not headline” X prompt pass | 4a | S | prompt |
| 11 | Weekly thread surface | 4b | M | code |
| 12 | Poll support + 2–3 polls/week | 4c | S–M | code |
| 13 | x_audience + x_surface_perf telemetry + ops line |
5 | M | code |
| 14 | Trend beat (2/day, names the trend) | 5 | M | code |
| 15 | Reply lane (the desks’ third lane) — SHIPPED | 1b | — | kick_reply_lane in the wire desks |