Content generation
The topic-selection, grounding, and dedup pipeline behind every generated draft.
GenerationService.generate(configId) is the single entry point every draft goes through,
whether it was triggered manually from Content Studio or fired by a config's recurring schedule.
The steps
Pick a topic
topic-selector.ts is a pure function choosing between rotate, weighted, or random strategies
over the config's assigned topics. The weight it works from isn't just what you set manually. See
The loop for how real engagement multiplies it.
Optionally pull live news
If the chosen topic is flagged for live news, the news module calls OpenRouter's web-search
plugin, scoped by the config's news keywords if any are set.
Assemble the prompt
context-composer.ts's composeGenerationContext calls four independent providers and merges
their output: brand voice (from the account's knowledge profile), anti-repeat history (recent
posts to avoid echoing), the news pulled above, and the account's own performance summary (a
bounded aggregate read straight off SocialAccount.performanceSummary, no live elizaOS query
needed on the hot path).
Generate and validate
The assembled prompt goes to GatewayService.complete() (the single point every LLM call in the
system passes through, see below), and the result is checked against two things: a Postgres
pg_trgm similarity check plus a SHA-256 exact-match check for duplicates, and a 280-character
weighted length check via X's own twitter-text parser. A failure on either retries the whole
generation, up to maxRegen + 1 times.
Persist or auto-publish
A draft that passes is saved as pending_approval. If the config has requireApproval: false
("autopilot"), it's enqueued for publishing immediately instead. If that enqueue call itself
throws, the post is marked failed rather than left silently stranded in an ambiguous state.
The gateway is the only way out to the model
OpenRouterClient is the sole egress point to the LLM provider: a deliberate LiteLLM-gateway-
style pattern. GatewayService.complete() wraps every call with a budget gate
(billing.ensureBudget(), see Cost & Credits), the call itself, a cost
record, and a credit debit. Nothing else in the codebase is allowed to call OpenRouter directly.
Reply generation is the same shape, deliberately smaller
ReplyGenerationService mirrors this pipeline for Reply Manage
(brand-voice grounding, gateway call, length check) but skips topic selection, news, and
dedup-against-corpus entirely. A reply doesn't need a topic to rotate through or a news hook; it
needs to sound like the account and fit the platform's length limit, and that's what it checks.