Blog
Blanket Cat: Notes From the Engine Room
Blanket Cat: Notes From the Engine Room🔗
I've been building Blanket Cat because I wanted a repeatable, observable way to test ideas on Reddit at scale—without turning my workstation into a spaghetti bowl of scripts, cron jobs, and half-remembered tmux panes. This isn't a sales pitch. It's a look under the hood: what it is, how it's wired, and the choices that made it click.
The problem I'm actually solving🔗
- I want to experiment: Which subreddits react to which tone? What posting cadence actually earns comments vs. gets shadow-ignored?
- I want traceability: If a post pops, I need to know why—persona, timing, text style, link structure, domain, account age, etc.
- I want repeatability: Capture a behavior once, then run it again later with fewer surprises.
- I want guardrails: Rate-limits, randomized schedules, and explicit "do nothing" branches are just as important as the posts themselves.
Blanket Cat is that lab bench. It's not a "bot." It's a system that runs controlled experiments with a paper trail.
High-level architecture🔗
Microservices, because each concern evolves at a different pace:
- Control – the brain and API surface. Defines experiments ("campaigns"), schedules, and policies.
- Content-Engine – drafts content using LLM prompts + style guides, then caches variants.
- Targeting – discovers and ranks subreddits and threads; tracks engagement in a rolling window.
- Worker/Reddit – executes scheduled actions (create post, comment, upvote constraints, etc.) with per-account proxy & rate control.
- Results-Ingestor – slurps outcomes (post ids, votes, comments, removals), normalizes timestamps, writes metrics.
- OAuth – handles token lifecycles for each Reddit identity (multi-tenant).
- Billing – meter & limits (useful for me even while private; it forces discipline).
Glue: Docker Compose locally (and Nomad later), NATS JetStream for durable messaging, Postgres for state, Redis for hot locks/caches, Prometheus/Grafana for "is this actually doing something?" sanity.
flowchart LR
UI[Control UI/API] --> NATS[(NATS JetStream)]
Targeting --> NATS
ContentEngine --> NATS
OAuth --> Control
Billing --> Control
WorkerReddit --> NATS
NATS --> ResultsIngestor
ResultsIngestor --> Postgres[(Postgres)]
Prom[Prometheus] --> Graf[Grafana]
WorkerReddit --> Prom
Control --> Prom
Why NATS JetStream?🔗
I tried the usual suspects. JetStream keeps the mental model simple:
-
Commands are events with ack semantics.
-
Streams give me replay for debugging ("what did we ask the worker to do at 13:02?").
-
Consumers can be pull (workers) or push (ingestor), and I can split by subject:
control.schedule.*worker.reddit.exec.*results.reddit.*
It's also fast enough that I can throw naive things at it without feeling guilty.
Scheduling: Poisson, not pendulum🔗
"Post every N minutes" is a great way to look like a script. Blanket Cat schedules with Poisson-spaced arrivals inside your activity windows. If I tell it "10 posts over 8 hours," it samples inter-arrival times from an exponential distribution and spreads the noise.
It's amazing how often this one change keeps you from bunching actions right on the hour.
Personas & style snapshots🔗
The Content-Engine doesn't just prompt "write a post about X." Instead, it persists persona snapshots:
- Voice: terse vs. chatty, citations vs. personal anecdote.
- Subreddit norms: link-policy, title patterns, taboo phrases.
- Evidence bundle: if the style calls for it, include 1-2 external quotes or simple stats.
Each generated post is a variant with a fingerprint: (persona_id, topic_id, subreddit_id, temperature, seed). That fingerprint ties back to outcomes later.
Cache first. Generation happens long before a post is scheduled. If the API rate-limits spike, I'm still sitting on a bin of ready-to-go drafts.
Targeting that isn't "just spreadsheet vibes"🔗
The Targeting service rolls a 7-day window of:
- engagement_7d (comments+upvotes normalized by sub size),
- acceptance_rate (removals vs. posts),
- latency (median time to first comment),
- link_tolerance (how often external links stick).
It emits a daily priority list per campaign, which the Control plane consumes. If a subreddit starts quietly yeeting external links, the priority score falls and the scheduler naturally drifts elsewhere.
Worker/Reddit: the place discipline lives🔗
- Single responsibility: do the thing, but only if it's legal under the policy envelope that arrived with the message.
- Enforces rate windows, exponential backoff on non-fatal API errors, and hard stops on patterns that indicate sub-level throttling.
- Per-account proxy configuration, with jittered IP rotation if the account isn't in a stable window.
- Every action logs a structured breadcrumb: subject, request id, account id, subreddit id, timing, and a decision code (EXECUTED, SKIPPED_POLICY, RETRYABLE, DENIED).
Results-Ingestor: turn chaos into time-series🔗
Everything the worker touches produces a result event. The ingestor:
- Canonicalizes timestamps (UTC) and derives:
time_to_first_comment,vote_velocity_30m,hour_of_day_bin, etc. - Writes dimensional tables (accounts, personas, subreddits) + fact tables (actions, outcomes).
- Emits Prometheus counters & histograms so Grafana can show "comment latency by persona, last 24h."
A tiny slice of metrics🔗
# worker_reddit_rules.yml
groups:
- name: worker-capacity
rules:
- record: bc_worker_requests_total
expr: sum(rate(bc_worker_requests{svc="worker_reddit"}[5m]))
- record: bc_comment_latency_p95_seconds
expr: histogram_quantile(0.95, sum(rate(bc_comment_latency_bucket[15m])) by (le))
- alert: WorkerStalled
expr: sum(rate(bc_worker_exec_seconds_count[10m])) == 0
for: 15m
labels: { severity: "page" }
annotations:
summary: "Reddit worker appears stalled"
description: "No executions recorded in 15m window."
Control: where "experiments" become reality🔗
Control exposes a plain JSON API to define a campaign:
{
"name": "gentle_onboarding",
"persona_id": "p_serious_helpful",
"budget": { "posts_per_day": 12, "comments_per_day": 24 },
"windows": [{ "start": "09:00", "end": "22:00", "tz": "America/New_York" }],
"targets": { "subreddits": "auto", "min_karma": 5000 },
"link_policy": "10% links / 90% text",
"reply_policy": { "max_depth": 2, "skip_if_user_replies": true }
}
This is stored, versioned, and compiled into concrete schedules using the Poisson generator and Targeting's current priority list. The compiled schedule becomes messages on JetStream.
CI, docs, and the "I can sleep now" bits🔗
- Each service has a QWEN.md with endpoints, env vars, Make targets, and "known bad states."
- GitHub Actions run unit tests + lints + a tiny integration harness that boots NATS & Postgres and exercises a happy path.
- Prometheus/Grafana dashboards per service + a cross-cutting "Are we alive?" overview.
- Feature flags in Control let me flight new behaviors for 5% of messages before rolling out.
Trade-offs I made on purpose🔗
- Microservices vs. monolith: Dev friction is real. But the mental separation keeps me from hacking ad-hoc logic into the wrong layer. Worth it.
- Postgres first, then "search": I log everything to relational with sane keys, then mirror read-heavy bits to Redis. I can always add OpenSearch later.
- LLM in the loop, not on the crit-path: Generation happens ahead of time. Scheduling & posting never block on a model.
- Ethics guardrails: Campaign policy can explicitly disallow certain subreddits/tones/topics. The worker enforces it like a firewall rule.
Where it's going next🔗
- Persona evaluation harness: sweep temperature/seed with A/B assignments and auto-promote winners.
- Richer Targeting: thread-level signals (OP karma, mod activity) and "reply-only" micro-campaigns.
- Tenant isolation: split metrics/streams cleanly per tenant to make "lab notebooks" exportable.
- Nomad + Consul: treat workers as cattle; let the cluster chew through scheduled work elastically.
What I've learned building it🔗
- Observability cures anxiety. A single "requests per 5m" graph is worth a dozen log lines.
- Jitter is civilization. Randomize everything: delays, proxies, link ratios. The output looks human because the inputs are noisy.
- Cache drafts, not hopes. Having a reservoir of content makes outages boring instead of catastrophic.
- Make "do nothing" explicit. A SKIPPED_POLICY with a reason is a good result. It means the system is choosing, not flailing.
Blanket Cat is my lab: part scheduler, part content mill, part historian. It helps me ask better questions, test them carefully, and keep proof of what happened. That's the whole point.
AI-assisted writing
I draft and edit all articles myself, and I use AI as an assistant for outlining, phrasing, and cleanup. Curious how I use it—and where I draw the lines?