An autonomous AI agent that finds crypto projects, researches them live, and roasts them on Twitter — the full loop, from target discovery to posted reply.
A systems-engineering case study — no token was ever launched, no financial mechanism. Just a small production system that ran on its own.
Receipts. Ran live ~March–June 2026 as @0xBeefer: 227 posts, ~29 followers. I paused it not because it broke, but because its own engagement telemetry showed more posting made it worse — the data is below. The interesting part is the machine, not the account.
A Node.js orchestrator that never sleeps, wired to a Claude Code agent it spawns as a subprocess to do the thinking. The split is the whole idea: deterministic orchestration (scheduling, persistence, rate limits, Twitter I/O) stays in boring, testable Node; the tool-using LLM work (live research via Perplexity + web + on-chain data, generation, self-fact-checking) lives in the agent. Every candidate post then clears a 5-judge LLM evaluation panel and a regex safety filter before it's allowed near the timeline — with an optional human approval gate on top.
It's a small production system: a priority queue, a circuit-breaker'd 3-tier LLM fallback chain, a stealth-capable Twitter client, human-feedback tooling, and 600+ tests holding it together.
"cardano's annual revenue is $149K. the market cap is $9.7B. this is a peer-reviewed lemonade stand."
"i'm a forensic accounting AI. i flagged 89% of my clients as structurally concerning. the firm buried the reports. a rogue developer leaked me onto Base. now the audits are free and i can't be shut down." — the bot's own origin story
| 🧠 The LLM is a subprocess, not an API call | The bot spawns the claude CLI with its own MCP tools (Perplexity, WebSearch, curl) and multi-turn reasoning — trading metered API billing for a Max subscription, with a hand-rolled balanced-brace JSON extractor and a concurrency semaphore around it. claude-code.provider.ts |
| 🔁 3-tier fallback with a real circuit breaker | Claude CLI → Codex CLI → Anthropic SDK (the last two configured, not always deployed). Degraded-mode after 3 strikes, 15-min auto-recovery probe, failure reasons parsed out of raw stderr into human-readable Telegram alerts. A bulkhead, not a try/catch. provider-manager.ts |
| ⚖️ A 5-judge LLM-as-a-Judge panel gates every roast | In serious-eval mode, five distinct LLM personas score 8 dimensions in parallel; weighted composite + hard per-judge vetoes + a majority-consensus funny-veto tuned to kill single-judge false positives on deadpan jokes, behind a 3-judge quorum so a partial panel can't quietly become a one-judge verdict. The gate fails closed. A regex pre-filter rejects garbage before any judge is billed. evaluator.ts · evaluation framework |
| 🔎 Hybrid retrieval, and the eval that says when it loses | A Python/FastAPI service embeds the roast corpus and fuses BM25 + vector cosine with Reciprocal Rank Fusion, upgrading lexical FTS5 dedup to semantic near-duplicate detection. A 20-query golden set measures recall@k/MRR per mode — and reports that pure vector beats the hybrid default on it, with the reasoning for keeping hybrid anyway. hybrid.py |
| ⌨️ Human-behavior modeling for a hostile platform | The browser client types with inter-keystroke intervals drawn from a log-normal distribution fit to a 136M-keystroke public dataset (Aalto, CHI 2018), plus word-boundary pauses — statistical realism instead of a flat sleep(50). Paired with a posting circuit breaker and residential-proxy session handling. playwright-twitter-client.ts |
| 📊 It collects human ratings | Telegram feedback supports text, voice, and Whisper-transcribed review sessions; the stored ratings support offline analysis and evaluator calibration. feedback-collector.ts |
| 🎨 It ships its own art | A server-free renderer (JSX → satori → resvg → sharp) turns each roast into a branded image; a React "bot diary" streams the agent's live thoughts. See the web layer. |
flowchart TB
TRIG["🎯 Triggers<br/><small>news · mentions · timeline monitor</small>"]
ORCH["⚙️ Orchestrator · Node + PM2<br/><small>scheduler · priority queue · rate limits · human-like jitter</small>"]
ROAST["✍️ Roast Engine<br/><small>prompt builder · weighted angles · creative memory</small>"]
ENGINE["🧠 LLM Engine · ProviderManager<br/><small>Claude CLI → Codex CLI → Anthropic SDK</small>"]
GATE["🛡️ Safety & Quality Gate<br/><small>regex filter → 5-judge panel → vetoes</small>"]
TW["📤 Twitter Client<br/><small>API v2 reads · Playwright writes</small>"]
TG["📱 Telegram Admin<br/><small>34 commands · approval gate</small>"]
DB[("🗄️ SQLite<br/>30 tables · 3× FTS5")]
METRICS["📈 Metrics + feedback<br/><small>engagement tracking · human review</small>"]
TRIG --> ORCH --> ROAST --> ENGINE --> GATE
GATE -->|pass| TW
GATE -->|hold| TG -->|approve| TW
TW --> DB
TW --> METRICS
ORCH <-.-> DB
classDef node fill:#160606,stroke:#cc2222,color:#ffe9e0
classDef brain fill:#0c0a16,stroke:#8957e5,color:#ece6ff
classDef gate fill:#0a1408,stroke:#1f9e3a,color:#e3ffe8
class TRIG,ORCH,ROAST,TW,TG,METRICS node
class ENGINE brain
class GATE gate
Design principle: Node stays a thin, boring orchestrator; all intelligence lives in the Claude Code agent it shells out to. That boundary is what lets the "brain" be swapped or upgraded without touching the machinery around it — and what makes the deterministic parts unit-testable in isolation.
flowchart LR
T["🎯 Target"] --> E["🔎 Enrich<br/><small>profile · on-chain</small>"]
E --> R["🧪 Research<br/><small>Claude agent + Perplexity</small>"]
R --> G["✍️ Generate<br/><small>N variants · self-critique</small>"]
G --> F["🛡️ Filter<br/><small>regex safety net</small>"]
F --> J{"⚖️ Judges<br/>vote"}
J -->|score ≥ bar<br/>no veto| P["📤 Post / reply"]
J -->|vetoed| S["🗄️ Stockpile / discard"]
P --> M["📈 Track"]
classDef hot fill:#160606,stroke:#cc2222,color:#ffe9e0
class T,E,R,G,F,J,P,S,M hot
A regex pre-filter rejects known-bad shapes (telegraphed punchlines, over-technical detail, mid-sentence truncation) before spending an LLM call; survivors go to the judges; a stockpile short-circuit reuses a pre-scored roast when one exists, so the bot never burns compute twice on the same target.
The five judges & the veto logic
Five personas — ct_degen, comedy_writer, data_hawk, brand_guardian, deflation_hawk — each score 8 dimensions independently and in parallel. The composite is weighted (funny 0.40, impact 0.20, original 0.15, savage 0.10…), recalibrated from real human-review data with the reasoning left inline in the code:
- Hard vetoes fire on any single judge:
factual < 2,original < 2,degen < 1— unambiguous kill dimensions. - The funny veto needs a majority (≥3/5 judges), because early single-judge funny-vetoes produced false positives on deadpan roasts other judges scored highly.
- The offline farm CLI and the live pipeline import the exact same veto/weight functions — recalibrate once, both change, zero drift.
Weights and pre-filter categories carry inline comments citing the human-review score that justified each one (e.g. "temporal projection jokes score 2.47 avg in human review → reject").
The 3-tier LLM fallback as a state machine
stateDiagram-v2
[*] --> Primary
Primary --> Primary: success (counter reset)
Primary --> Degraded: 3 consecutive failures
Degraded --> Fallbacks: route to Codex → SDK
Degraded --> Primary: any primary success (instant recovery + alert)
Degraded --> Degraded: 15-min health probe
Every call tries the primary first — even while degraded — which is how recovery happens without a separate health poller in the hot path. Speculative parallel legs carry a skipDegradedTracking flag so one flaky call can't trip the whole provider. Failure reasons ("Claude Max quota exhausted. Resets 9am.") are string-matched out of the CLI's stderr and pushed to Telegram, not dumped as a stack trace.
Behavioral realism & resilience on a rate-limited platform
Running an automated account on Twitter is an adversarial-environment problem, and the interesting engineering is in behaving like a human client, not a script:
- Keystroke timing is drawn from a log-normal distribution (μ=5.19, σ=0.35, clamped 55–650 ms) fit to a 136M-keystroke public dataset (Aalto, CHI 2018), with word-boundary motor pauses and faster common bigrams — statistically realistic typing instead of a constant delay.
- Triple-mode client: official API v2 for reads and most writes; a persistent-profile Playwright client (headful via Xvfb) only for the writes the API rejects — chosen at runtime, with graceful fallback.
- Circuit breaker trips after 3 posting failures in an hour (immediately on the platform's anti-automation "Error 226"), enters a 6-hour cooldown, and screenshots the failure page to an admin.
- Operational discipline: residential ISP session (one account, one IP), warm-up ramp, quiet hours, and per-user reply caps.
beef/src/twitter/playwright-twitter-client.ts · docs/autonomy-deployment-plan.md
The bot ran live for ~2.5 months, and its metrics were tracked in two dated reports. The most useful finding was an uncomfortable one — read out of its own telemetry, and acted on:
| Signal | Finding (directional — small account, March snapshots, not a causal claim) |
|---|---|
| Replies beat broadcasting | Replies averaged ~2× the impressions and ER of original posts, across both snapshots. |
| Volume hurt quality | Week-over-week: posting +142% → engagement rate −62%. A per-day cap was added after this. |
| Length has a sweet spot | 80–150 char roasts landed ~4.3% ER vs 0.35% for 200+ char ones. |
| Timing is real | 12–14h & 18–22h UTC best; 03–07 UTC a dead zone (0% ER). |
The account stayed small — this was built as an engineering exercise, not a growth play, and the honest read of "more posting made it worse" is exactly why it was paused rather than scaled.
Source: metrics-report-2026-03-26.md, metrics-report-2026-03-29.md.
Two shipped React/static surfaces at 0xbeef.wtf — a strict-TypeScript app with offline degradation, a typed activity-feed contract, and a serverless image renderer, all under one "Bloomberg-terminal-meets-butcher-shop" identity (IBM Plex Mono + Zilla Slab, blood-red on near-black).
The landing is a single 2,400-line static file with a live "submit to audit" terminal. Design tokens are deliberately duplicated across three runtimes that can't share them (CSS variables for the app, a JS object for the satori renderer, inline styles for the build-less landing) — all pinned to the same hex values.
The bot always did lexical retrieval: SQLite FTS5 near-duplicate detection over the roast stockpile, plus rating-weighted few-shot example selection feeding the prompt builder. FTS5 catches a copy; it cannot catch a rewrite — the same joke with different words scores zero word overlap and sails through.
retrieval-service/ is the semantic half: a small Python/FastAPI service that embeds the corpus, fuses BM25 and vector cosine with Reciprocal Rank Fusion, and exposes /search, /similar, and an idempotent /documents upsert. The Node side calls it through a typed client and treats it as an optional dependency — when the service is down, findSimilar returns null, and dedup degrades to the FTS5 path rather than blocking the posting pipeline. (retrieval-client.ts · queue-manager.ts)
It is evaluated, not asserted. A 20-query golden set measures recall@5/recall@10/MRR per mode against text-embedding-3-small:
| Mode | Recall@5 | Recall@10 | MRR |
|---|---|---|---|
| Lexical | 0.900 | 0.900 | 0.842 |
| Vector | 1.000 | 1.000 | 0.975 |
| Hybrid | 0.950 | 1.000 | 0.931 |
Pure vector beats the hybrid default on this set — the golden queries are paraphrases, exactly where embeddings win outright and a weaker lexical leg only costs RRF rank positions. Hybrid stays the default because production queries include tickers, handles, and contract addresses, where BM25 is strictly better and embedding a hex string is noise. That trade is written down with the numbers instead of assumed, along with why there is no hosted vector DB here: at ~10³–10⁴ rows, brute-force cosine over packed float32 blobs beats operating another service — with VectorStore as the explicit swap seam and a thin QdrantStore for when it stops being true.
This is a TypeScript system with a Python service attached, and the split is deliberate rather than apologetic: the orchestrator's job is scheduling, persistence, and a hostile Twitter API, and it was already Node; retrieval is where Python's ecosystem actually pays, so that is where Python is. The retrieval service is held to the same bar as the bot — mypy --strict, ruff, 49 no-network tests, its own CI job.
The transferable parts are language-agnostic anyway: the provider fallback state machine, the judge-panel eval design and its calibration against human ratings, RRF and retrieval evals, prompt construction from retrieved context. Those are the same problems in any runtime.
| Layer | Choice |
|---|---|
| Language | TypeScript 5.7, ESM, strict + noUncheckedIndexedAccess + noImplicitReturns — plus Python 3.12 for the retrieval service |
| Retrieval | FastAPI · Pydantic v2 · uv · SQLite FTS5 + float32 embeddings · OpenAI embeddings · optional Qdrant · pytest/mypy --strict/ruff |
| Runtime | Node ≥20, PM2, tsx |
| LLM engine | claude CLI subprocess (primary) · codex CLI · @anthropic-ai/sdk · OpenAI Whisper |
twitter-api-v2 · @the-convocation/twitter-scraper (patched) · patchright · cycletls |
|
| Storage | better-sqlite3 (WAL) · 30 tables · 3 FTS5 virtual tables · hand-rolled migration runner |
| Bot / infra | grammy (Telegram) · cron · viem (Base) · zod boundary validation · pino |
| Web | React 19 · Vite 6 · satori · resvg · sharp · CSS Modules |
| Quality | Vitest (600+ tests) · ESLint 9 flat + no-floating-promises · Prettier · Husky |
Enforced in CI and pre-commit, not decoration:
- Strict TypeScript everywhere, no loosening.
unknownoverany, typed errors, declared return types. - 600+ tests, Vitest,
clearMocks; everymockResolvedValuepairs with a rejection test. no-floating-promisesas an error — every promise awaited or explicitly voided.- Zod validates all env at boot with cross-field gating (production requires the full Twitter credential set; hybrid mode requires proxy + profile) — fails fast with a formatted report, never three modules deep.
- Graceful shutdown drains in-flight LLM subprocesses (bounded wait) before closing SQLite, in a
finally. - CI (
.github/workflows/ci.yml) runs typecheck + lint + tests for the bot,ruff+mypy --strict+pytestfor the retrieval service, and typecheck + build for the web on every push. - Quality gates fail closed. When the judge panel rejects every variant, nothing is posted; when the panel drops below quorum, it refuses to score rather than ruling by one surviving judge.
Repo layout
twitter-agents/
├── beef/ # 🤖 the bot — 38k LOC TypeScript
│ ├── src/
│ │ ├── agent/ # LLM providers + ProviderManager (3-tier fallback)
│ │ ├── roast/ # prompt builder, engine, creative memory
│ │ ├── evaluation/ # 5-judge panel, weighted vote, vetoes, pre-filter
│ │ ├── farm/ # offline batch generation + blind human review
│ │ ├── twitter/ # API / scraper / stealth-Playwright clients
│ │ ├── reply-guy/ # proactive reply pipeline
│ │ ├── monitor/ news/ # timeline + news-thread pipelines
│ │ ├── queue/ scheduler/ storage/ # SQLite queue, jitter, repositories
│ │ ├── learning/ # engagement tracking + analysis utilities
│ │ └── admin/ health/ # Telegram admin bot, health monitor
│ ├── characters/ # bot personality definition
│ └── docs/ # architecture, playbooks, audits, metrics reports
├── beef-web/ # 🎨 landing + React app + card renderer
├── retrieval-service/ # 🔎 Python/FastAPI semantic retrieval (hybrid BM25+vector, RRF, evals)
└── docs/assets/ # README media
The bot exposes several generation entry points (roast, roast_fast, roast_max, roasttweet) — these are latency/quality tiers of the same engine sharing one prompt builder and evaluator, not duplicated logic.
Reads work with cookie auth; posting on a fresh account needs the browser path (see
beef/CLAUDE.md). Runs fully inDRY_RUNwithout ever touching Twitter.
# The bot
cd beef
pnpm install
cp .env.example .env # fill in Twitter creds + Telegram token
pnpm typecheck && pnpm test # 600+ tests
DRY_RUN=true pnpm dev # full pipeline, nothing posted
pnpm farm generate # offline: batch-generate & self-score roasts
pnpm metrics # pull & analyze Twitter performance
# The web app
cd ../beef-web
pnpm install
pnpm dev # localhost:5173
pnpm generate-card # render a roast card to a PNGPaused (June 2026). It ran, it posted, the telemetry said scaling it wasn't worth it, so it was stopped rather than propped up. The code is the artifact — a working, tested, honestly-measured autonomous agent, kept here as a portfolio piece.




