Niches and Writer RAG Modes
Poindexter 1.0 ships a niche-aware topic-discovery flow and four writer RAG modes. The engine no longer assumes an evergreen-tech editorial voice — your install is one of many possible niches (real estate, fashion, finance, indie game devlog, etc.), and the writer can be configured per niche to ground drafts in your own internal corpus instead of summarizing external feeds. This doc is for operators who want to understand what the new flow does, when each writer mode is the right pick, and how to drive it from the CLI or MCP. This page is the operator surface only.Why this exists
Before 1.0, the pipeline summarized external content (Hacker News, dev.to, web search) and proposed one topic at a time. Most proposals were rejected by the operator at the title gate, which meant the full research → draft → QA cost was burned on posts that lost at the first human touch. The new flow gates earlier and grounds deeper:- Earlier: one LLM call ranks a batch of 5 candidates against your niche’s weighted goals. You pick a winner before any draft is written.
- Deeper: the writer can pull from your own corpus (past sessions, brain knowledge, audit log, git history, prior decisions, memory files, post history) instead of paraphrasing what someone else already published.
starter-blog niche so the new flow is
the default; you can configure additional niches per install to run the
same engine on different editorial voices.
Concepts
Niche
A row in theniches table. One row per audience the operator
publishes for. Each niche owns its writer_rag_mode, batch_size,
discovery_cadence_minute_floor, an optional writer prompt override,
and a list of target_audience_tags.
Niche goal
A weighted entry inniche_goals. Each goal pulls from a fixed
vocabulary so the ranker can score candidates consistently across
niches:
Weights are integer percentages and must sum to ~100 per niche.
Topic batch
The unit of operator interaction. A discovery sweep produces onetopic_batches row per niche with status='open' and batch_size
candidates (default 5). At most one open batch per niche exists at a
time (enforced by a partial unique index). Open batches expire after a
configurable window so dead batches don’t block the niche forever.
Candidate
Either atopic_candidates row (external — HN, dev.to, web_search,
etc.) or an internal_topic_candidates row (RAG-derived from your
corpus). Both carry a score, a score_breakdown JSONB of
goal-contribution percentages, a rank_in_batch, an operator_rank,
and an optional operator title/angle rewrite.
Unpicked candidates carry forward with a decay_factor of 0.7
applied each cycle, so a near-miss this week stays in the running next
week with diminishing weight.
Discovery flow
A sweep runs per niche and is triggered by either:- Reactive trigger — the previous batch transitioned to
resolved, and the niche’sdiscovery_cadence_minute_floorhas elapsed. - Operator on demand —
poindexter topics discover --niche <slug>(also subject to the floor; pass--forceto bypass).
- Picks a niche where the floor has elapsed and no open batch exists.
- Asks each enabled
niche_sourceplugin for candidates proportional to itsweight_pct. Target pool size is ~20. - Loads carry-forward candidates from the previous batch and applies the 0.7 decay.
- Embedding pre-rank. Cosine-similarity against a precomputed
“goal vector” per goal type, weighted-summed across the niche’s
goals × the candidate’s
decay_factor. Top 10 advance. - LLM final scoring. One call returns a JSON
{candidate_id: {score, score_breakdown}}against the niche’s weighted goals. - Top
batch_sizecandidates land intopic_candidates/internal_topic_candidates, a newtopic_batchesrow opens, and the existingtopic_decisiongate flags “operator action needed.” - The run is logged to
discovery_runsfor observability.
External-candidate internal grounding (#822)
External sources (hackernews / devto /web_search) are a popularity signal
— but a popular headline with no first-party angle produces a zero-new-value
rewrite. So during step 4, each external candidate’s pre-rank score is
additionally weighted by whether the operator’s own corpus already covers the
topic. services/topic_grounding.py runs one pgvector nearest-neighbor query
for the candidate’s (already-computed) embedding against the content-bearing
corpus — posts + memory (decision_log / memory_file) + claude_sessions,
tunable via niche_external_grounding_source_kinds. Ops-noise kinds (audit,
brain) are deliberately excluded so a status row can’t manufacture grounding.
If the best cosine similarity is below niche_external_grounding_threshold
(default 0.55, provisional — calibrate from a real sweep’s logged
_grounding distribution), the score is multiplied by
niche_external_grounding_penalty_factor (default 0.6). This is a soft
penalty: a highly popular ungrounded topic can still win, but a grounded one of
similar popularity is preferred. Internal candidates come from the corpus
and are never penalized. The lookup fails open (treated as grounded) on
any error or empty corpus, so it never sinks a sweep. Master switch:
niche_external_grounding_enabled (default true).
The matched internal snippet is persisted on the winning row
(topic_candidates.grounding_ref) and threaded into the task handoff metadata
(metadata.internal_grounding), which the writer then opens on (see
Writer consumer below). Penalized candidates emit an advisory
external_topic_ungrounded finding (visible on the Findings board plus a
dedicated Pipeline-board panel). Full design:
2026-07-10-822-external-internal-grounding-design.md.
Writer consumer (#822 half 2). When a grounded external topic reaches the
canonical_blog writer, GenerateContentStage._read_internal_grounding reads
the threaded match from stage_data.metadata.internal_grounding and passes it
into two_pass_writer.run(internal_grounding=…). _draft_node renders it as a
soft PRIOR WORK section appended after the SOURCES block — a framing
anchor the writer is told to use only where there’s a genuine throughline, in
our own voice, and never to parrot. Eligibility rides the writer’s existing
writer_rag_source_filter (default posts-only, so ops content stays out of
the public prompt unless opted in); a posts match gets an inline
/posts/<slug> link. Governed by writer_internal_grounding_enabled (default
true); fail-open (no match / disabled / ineligible / scrub failure →
byte-identical prompt) and scrub fail-closed on the preview. Full design:
2026-07-10-822-writer-internal-grounding-design.md.
This is the complementary half of #820 (internal_rag storyworthiness):
#820 makes the internal source pick material worth writing; #822 makes
external sources bend toward what the operator already knows.
Writer RAG modes
Set per niche onniches.writer_rag_mode. Tasks without a
writer_rag_mode set fall back to the legacy generator, so pre-niche
pipelines are unchanged.
TOPIC_ONLY
Writer gets the topic + angle, runs one embedding query against
your corpus, and gets the top-N internal snippets dropped into the
prompt as background context. Single-pass, no enforcement.
When to use it:
- You want internal grounding without a hard citation contract.
- The niche is broad enough that any one query covers the surface area.
- Lowest LLM cost of the four modes.
CITATION_BUDGET
Writer must hit at least N internal citations. The existing
content_validator extends its citation rules to enforce the floor;
drafts under-budget are rejected before QA.
When to use it:
- You’re publishing under an “authority” or “niche-depth” goal where unsupported claims need to be cut.
- You have enough internal corpus to support N citations on most topics in the niche.
STORY_SPINE
A preprocessing LLM call reads the top 10–15 internal snippets and
produces a structured outline. The writer then expands the outline.
When to use it:
- Long-form posts where structure matters more than prose density.
- Topics where internal sources naturally tell a story (decision history, postmortems, journey-style retrospectives).
TWO_PASS
The Glad Labs default and the most expensive mode.
- First pass: internal-context-only draft. No external research call. The writer uses only the corpus snippets the retriever pulled.
- Second pass: the writer detects
[EXTERNAL_NEEDED]markers in its own draft, runs a bounded external research step for each, and revises. The state machine is capped at 3 revision loops so it can’t run away.
TWO_PASS is implemented as a LangGraph state machine, which gives the
flow operator-interrupt checkpointing for free — a batch sitting with
the operator for days resumes cleanly when they come back.
When to use it:
- First-person reporting on something nobody else has covered (your own infrastructure, your own decisions, your own data).
- Niches where authenticity beats coverage.
- You can afford the extra LLM passes.
CLI
The new operator surface lives underpoindexter topics and a new
poindexter niche subgroup.
poindexter niche:
MCP
The same surface is exposed as MCP tools so Claude Code (and the voice bot) can drive the gate end-to-end:topics_show_batchrank_batchedit_winnerresolve_batchreject_batch
topics_show / topics_approve / topics_reject / topics_propose tools remain available for backwards
compatibility (operator approve = “advance system rank #1 unedited”,
operator reject = “discard batch”).
Configuring a niche
A minimal configuration: create the niche, set goal weights summing to 100, enable the sources you want, and pick a writer mode.Provenance
Every published post carries atopic_batch_id pointer back to the
batch the topic came from, so you can query posts → topic_batches → topic_candidates to answer “which batch did this post come from, what
were the alternatives, and how did they score?”
This is the data trail behind any future analytics on which goal
weights drive your accept rate.
Compatibility notes
- Installs are seeded with a
starter-blogniche pre-configured toTWO_PASS, so the new flow is the default out of the box. - Tasks created without a
writer_rag_mode(e.g. via directPOST /api/taskscalls that bypass the niche layer) fall back to the legacy generator. Pre-niche pipelines keep working unchanged. - Sweeps run one niche at a time in v1. Schema is multi-niche ready; the worker is serial.
- Pipeline gateway caps (max-N tasks awaiting approval) are tracked as a separate concern and out of scope for the niche flow.