tl-keyword-research — topic → validated filter set + the results you choose

Turn a topic into a *validated* content filter and the channels it selects.

How to use it

Claude Code
  1. Run the line below. It pulls the whole folder into ~/.claude/skills/tl-keyword-research, including the files SKILL.md points to.
  2. Describe your job in plain words. Claude Code follows the skill from there.
Claude Code — installs the whole folder, not just SKILL.md
npx degit ThoughtLeaders-io/thoughtleaders-cli/skills/tl-keyword-research#main ~/.claude/skills/tl-keyword-research

For one project only, change the path to .claude/skills/tl-keyword-research. This skill also uses build_report.py, search_videos.py, search_channels.py, expand_entities.py, probe.py, select_keywords.py — copying SKILL.md alone won't be enough. See the folder on GitHub.

Claude (web or desktop app)
  1. On this page open ⋯ → Download .md.
  2. Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
  3. Pick the file and Save. Claude shows the name and description and runs a security scan.
  4. Check the skill is switched on.
  5. Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
  1. ChatGPT: make a Project and paste it into Instructions.
  2. Neither? Paste it at the top of a new chat — it works for that chat.
Not working?
  • Check which app you pasted it into — the steps above name the right one.
  • Some skills need the paid tier of Claude or ChatGPT.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Source of tl-keyword-research — topic → validated filter set + the results you choose

Show the full text658 lines
nametl-blurbdescription
tl-keyword-researchbuild & validate keyword filter sets + context-validated channels| Turn a topic into a *validated* content filter and the channels it selects. Invoke whenever the user wants to find videos or channels by what they are about — topics, concepts, niches, not IDs or names: "a group/set of keywords for <topic>", "keywords that represent <topic> content", "find content/channels about <topic>", "find investing channels" — or when you'd otherwise hand-compose a `tl db es` content search. It expands the topic into candidate keywords (with a gated web lookup for post-cutoff entities), probes Elasticsearch for each (counts + samples), validates matches against the user's stated intent, refines a boolean filter over ≥3 rounds, and delivers a keyword-group filter set + a clickable report link + the results the user chose: trend data at the video level (matching uploads + prevalence), or channel targets classified by topic intensity (core / recurring / one-off) and context-validated with cheap agents, or both. It ASKS which deliverable and which run mode (quick single pass vs deep ≥3-round refinement) the user wants whenever the request doesn't say — never a silent default; `autonomous` / `--auto` means deep + both deliverables with every pause skipped. Keyword-distribution output (counts per keyword) is OPT-IN — only when the user explicitly asks for "keyword counts / distribution / how common is X". Also invoke for HELP asks about this skill — "help", "how does keyword research work", "what are my options", "describe this skill" — answered free from the built-in guide, no queries run.

tl-keyword-research — topic → validated filter set + the results you choose

Turn a fuzzy topic into a precise, validated content filter over our data — and whichever results the user actually wants from it: the trend picture at the video level, channel targets classified by how much they cover the topic, or both. The value is not brainstorming synonyms (anyone can do that, and a free YouTube search does it too) — it's writing real Boolean queries over the right fields, then validating that the matches are actually on-topic against our corpus. That combination is what our data makes possible and a plain keyword list does not.

The deliverable has one mandatory layer and two optional layers the user chooses between (never assume — see Choosing the path & deliverable):

  1. The topic itself (always). A filter set of keyword groups + a clickable report link that opens the platform with the filter applied (+ a persist config) — build_report.py. The topic and its prevalence (matching videos, distinct channels) is a complete deliverable on its own — a trend journalist writing "how big is this on YouTube" needs exactly this and may not want a channel list at all.
  2. Trend data (video level). The matching uploads — sortable by date/views, windowed — via search_videos.py. A creator who covered the topic once counts here.
  3. Channel targets. Channels classified by their relationship to the topic — core / recurring / occasional / one-off (search_channels.py --intensity) — then ranked, sense-checked, and flagged for sponsorability. Never assume "channels about the topic" means only channels entirely about it: for niche topics there may be almost none, and the real sponsorship market is channels that return to the topic repeatedly.

The canonical artifact is the keyword-group filter set: each group is a self-contained boolean query (an exclusion can be scoped to its own arm — ("mythos 5" | mythos5) -keto — which a flat CNF cannot express). The rendered boolean expression is recorded alongside for provenance and re-runs verbatim via search_channels.py --group / search_videos.py --group.

Read references/elasticsearch-content-search.md before writing queries — it covers the article-vs-channel doc types, the content fields (and ES's legacy channel field names), simple_query_string Boolean syntax, tokenization, the report-link keyword grammar, and why we return _source samples (the CLI drops ES highlight).

<SKILL_DIR> below is this skill's directory (the one holding SKILL.md).

When to invoke / skip

Invoke when the user wants videos or channels by content (topics, concepts, niches), gives seed keywords or an NL phrase to widen into a content filter, or you're about to hand-compose a tl db es content search — delegate here first.

Skip when:

  • The user has explicit channel/brand IDs or names → tl channels find / tl brands find.
  • Intent maps cleanly to a curated recommender tag (e.g. "Cooking channels") → tl recommender top-channels "<tag>". Don't re-discover curated tags by text match.

Help mode — explain yourself on request, for free

When the user asks for help, what this skill does, how it works, what the options/parameters are, or to "describe the skill" ("help", "how does this work", "what can you do here", "what are my options", "explain the flow"): run nothing — no queries, no scripts, zero credits. Read references/help.md and present it clearly, sized to what they asked (the full guide for "explain how this works"; just the relevant slice for "what sorting options are there?"). Close by offering to start a run. Mid-run option questions ("what does deep mean?", "what's the recurring tier?") get the same treatment — answer from the guide, then continue where you paused.

Choosing the path & deliverable — the user picks, never a silent default

Two choices shape the run, and both belong to the USER:

Path (how hard to refine):

  • Quick — one expand → probe → validate → deliver pass, no refinement rounds (≈ 10–20 credits). Even here, tokenization variants, entity-family expansion, and sample validation are mandatory — speed never excuses a shallow synonym list.
  • Deep — the full pipeline: ≥3 refinement rounds (Stage 4) plus materialization (Stage 5) (≈ 60–120 credits depending on how much gets materialized and validated).

Deliverable (what comes back besides the filter + link, which are always included):

  • Trend data — the matching videos + prevalence numbers (video level).
  • Channel targets — channels tiered by topic intensity, sense-checked, sponsorability-flagged.
  • Both.

How to pick:

  1. The request names it → run it, no questions. "quick"/"fast"/"just a starting set" → quick; "deep"/"thorough"/"take your time" → deep. "trend", "prevalence", "how big is this", "who's talking about it right now" → trend data; "channels to sponsor", "targets", "channels for [client]" → channel targets.
  2. Otherwise, ask before starting — ONE combined question covering whichever of the two is unstated, with the trade in a line each (include the rough credit costs above). Don't silently default: guessing quick hides what the skill can do; guessing deep spends credits the user never asked for; and assuming the user wants a channel list when they wanted trend data (or vice versa) answers a question they didn't ask.
  3. Exception: autonomous / --auto / "don't stop to ask" with no named path/deliverable means the deep path, both deliverables, every pause skipped — the user has asked for zero questions.

Autonomy within the deep path: at any point the user can say run autonomously / without pausing (or invoke with autonomous / --auto) — keep refining without checkpoints per Stage 4. The preference holds for the rest of the session unless revoked.

The pipeline (you orchestrate; scripts + cheap agents do the work)

Stage What happens Tooling
0 Set up intent, deliverable, operator, breadth judgment, scope; gated web entity resolution keyword-entity-resolver agent + expand_entities.py
1 Expand deep, creative candidate generation you
2 Probe counts + samples per candidate probe.py
3 Validate keywords on-intent check per keyword; scope calls to the user inline / select_keywords.py + keyword-relevance-validator agent
4 Refine ≥3 rounds of boolean composition, fitness, backtracking probe.py --mode sqs, search_channels.py
5 Materialize intensity triage, then trend data and/or channel targets, validated search_channels.py --intensity · search_videos.py --group · search_channels.py --group → fetch_context.py → keyword-context-classifier agent
6 Deliver filter set + report link + the chosen deliverables; offer to save build_report.py, tl-save-report

Narrate the run. The user must always know what's happening: one line at every stage transition — what you're doing, why, and the approximate credits spent so far ("Stage 2 — probing 18 candidates, one query each (≈25 credits so far)"). Never run a silent stage, and surface every drop/prune/failure as it happens, not just at the end.

Spend tokens as carefully as credits. Deterministic work belongs in the scripts (zero model tokens — tokenization variants, tier math, pruning, link building are all scripted). Judgment calls go to the cheapest capable model via the bundled agents: keyword-relevance-validator and keyword-context-classifier run on Haiku; only the web-reading keyword-entity-resolver runs on Sonnet. Don't do in your own context what a script or a Haiku batch can do.

Stage 0 — Set up: intent, deliverable, operator, breadth, scope

Keep the user's own sentence verbatim as the intent — it's the yardstick every validation judges against. Then state your assumptions so the user can correct them:

  • Deliverable — trend data (videos), channel targets, or both: the user's choice from Choosing the path & deliverable. Either way, the research runs at topic level (probe --level topic — videos are where the keywords live); channel-doc probes (--level channel) remain a tool for channel-fit checks and the existence probe below. Two calibration facts to establish early:
    • Does a channel-identity version of this topic even exist? One cheap channel-doc probe on the core term (probe.py --level channel --samples 3 "<core>") answers it: a tiny distinct-channel count means essentially no channel is about this topic — say so, and set expectations that the channel deliverable will be built from recurring-coverage channels (Stage 5's intensity tiers), not identity matches. Never return a near-empty "core" list as if it were the whole answer, and never pad it.
    • A one-off mention is not a target. For trend math every matching upload counts; for sponsorship targeting a channel that mentioned the topic once usually doesn't. The intensity tiers keep these apart — don't collapse them.
  • Operator. Default OR (union of a niche's facets); AND only for a true intersection ("both X and Y", composite nouns). Under AND, keep candidates inside the intersection — don't broaden each component independently.
  • Breadth judgment — say it back. There is no universal right result size. "Underwater basket weaving" returning a few dozen channels is a complete answer; "basketball" returning a few dozen is a miss. If the topic reads niche, offer to broaden; if broad, offer to narrow to a sub-area. This judgment is what Stage 4's coverage check measures against. The probe's per-keyword channels count (distinct channels) is the measuring stick.
  • Scope — state it. Everything is scoped to YouTube uploads and, at video level, longform by default; offer --content-type all (or short/live) if the user wants more.

Gated web entity resolution — only when memory can't. Expansion is the one input drawn purely from model knowledge, so it fails where knowledge does (the classic miss: expanding a launch to the company name and never generating the product, its version, or its sibling). Trigger a web lookup ONLY when the topic names an entity that postdates the knowledge cutoff, was recently renamed/rebranded, or is a live trend / insider-jargon-dense niche you cannot enumerate from memory. For a topic you know cold (cooking, retirement planning, basketball), skip it. When the gate fires, say so and why, then delegate to the keyword-entity-resolver sub-agent (Agent tool, subagent_type: keyword-entity-resolver) so the noisy page text never enters your context — it returns a compact JSON of names only:

topic: <the topic>
intent: <one sentence — what the user actually wants>
level: topic|channel
known: ["...", "..."]    # your own candidates so far — it fills the gaps

Turn its reply into probe-ready candidates with expand_entities.py — it generates the tokenization spelling variants, folds each family into one boolean group, pairs rename aliases into (old | new), and dedupes against --existing:

python3 <SKILL_DIR>/scripts/expand_entities.py --probe-batch \
  --existing "cannes lions" "advertising awards" < resolver.json \
  | python3 <SKILL_DIR>/scripts/probe.py --samples 5

Non-batch mode also returns collisions (a polluter watch-list for the Stage 4 NOT-rescue) and hashtags (for optional hashtags field targeting). Web is for entity NAMES, never synonyms / breadth / validation — breadth is the distinct-channels count over our index, relevance is judged only from this index's samples, and every web-sourced name earns its place through the same probe + validation as any other candidate (no benefit of the doubt; a hallucinated name probes to count: 0 and drops).

Stage 1 — Expand: go wide, go deep, go creative

A shallow synonym list is the #1 failure mode. Coverage is won or lost here.

  • Decompose the topic into facets and expand each one. An event/launch: lead-up → the launch → product specifics → sibling & ecosystem products → reactions → fallout → regulatory. A niche: sub-areas, tools, personas, jargon.
  • Expand every named entity into its whole family — company, product line, model/version, codename, sibling shipped alongside. (Live miss: searching anthropic for the Claude Fable 5 launch missed fable 5 (713 ch), claude mythos (620 ch — the sibling), and mythos 5 (433 ch) — most of the topic.)
  • Generate tokenization variants for every name/number. The index tokenizes fable5, fable 5/fable-5, and fable five as different terms that miss each other's documents — probe each spelling (solid / spaced / spelled-out / hashtag-handle) as its own candidate. No stemming either: expand inflections/plurals yourself (invest/investing/investments are distinct).
  • Candidates can be boolean groups, not just phrases — a self-contained simple_query_string like ("fable 5" | fable5 | "claude fable"), or a family carrying its own scoped de-noising ("mythos 5" | mythos5) -keto.
  • Scale the candidate count to the topic. A one-line niche: ~8–15. A multi-facet event: 30–50, covering every family and its spelling variants.

Rules:

  • Never add the bare over-broad root — single word or generic collocation. For "TikTok Shop" don't add tiktok; supply chain risk alone matches its own broad domain unless entity-qualified.
  • Mine the data, don't only brainstorm. After the first probe, read the on-topic samples for recurring terms you didn't think of and re-probe them. This data-driven discovery is what a synonym list can't do.
  • Brands: when the topic IS an entity/event, full entity-family expansion is mandatory (see above). For a generic topic, don't drift into naming specific brands unless the seeds contain one (then adjacent brands in the category are fine). No specific channel names (tl channels find). No random padding.
Stage 2 — Probe (probe.py)

One ES query per candidate → counts + samples for validation:

# topic level (videos): phrase candidates
python3 <SKILL_DIR>/scripts/probe.py --level topic \
  "tiktok shop" "selling on tiktok" "tiktok affiliate"

# Boolean candidates via simple_query_string (default_operator=and is set for you)
python3 <SKILL_DIR>/scripts/probe.py --mode sqs \
  '"tiktok shop" +(marketing|affiliate|ecommerce)' '("mythos 5" | mythos5) -keto'

# channel level (whole channels); JSON array on stdin also works
python3 <SKILL_DIR>/scripts/probe.py --level channel "cooking" "baking"

Output: {operator, level, fields, scope, keywords:[{keyword, count, documents, channels, subsumed_by, samples, …recency}], dropped, failed, recency}.

  • Two counts, always. documents = raw match total; channels = DISTINCT channels reached (cardinality agg). Channel docs are duplicated across quarterly indexes, so at channel level documents is meaningless — count (the ranking headline) is documents at topic level, channels at channel level. Judge niche size by channels.
  • Samples are collapsed to distinct channels, so one prolific channel can't flood the slots — that's what makes validation meaningful. Topic samples: title/summary (+ channel_id, category, url); channel samples: name/topic (+ channel_description, channel_id).
  • Scope is always-on (YouTube format 4; longform at topic level unless --content-type all|short|live) and echoed under scope — tell the user.
  • Recency rides the same query (no extra credits): topic level recent_documents/recent_channels over --recency-months (default 12); channel level active_channels (posts_per_90_days > 0 — channel docs have no date). Each keyword carries stale (absolute-first rule, so high-volume evergreens are never mislabeled) and thin (below floor but proportionally alive). Annotations only — nothing is dropped here.
  • failed lists candidates whose probe errored/timed out — retry those individually; they are not dropped keywords.
  • subsumed_by is informational (a broader phrase is present); pruning happens after validation, in build_report.py, or the broad root would always win.
  • --since/--until are topic-only (channel docs have no publication date).

SQS power for candidates (--mode sqs): trailing * catches inflections, ~1 absorbs typos, "a b"~2 catches near-phrases — research-only: the report filter set can't hold */~ (see the reference), so enumerate the surviving variants before delivery.

Stage 3 — Validate keywords against the intent

This fixes "the word is there but the topic isn't" — the step that separates this skill from a free YouTube search.

Inline (≤ ~15 candidates): read each keyword's samples against the verbatim intent. Drop off-intent keywords, count: 0, and redundant subsumed_by duplicates.

At scale (the keyword-relevance-validator sub-agent):

python3 <SKILL_DIR>/scripts/probe.py "tiktok shop" "selling on tiktok" "tiktok" > /tmp/kw_probe.json
python3 <SKILL_DIR>/scripts/select_keywords.py --emit-batch < /tmp/kw_probe.json > /tmp/kw_batch.json

Send the batch (prepending one line — intent: <one sentence>) to the keyword-relevance-validator agent (Agent tool), save the strict reply [{i,relevant}], optionally run a second pass for a majority vote, then:

python3 <SKILL_DIR>/scripts/select_keywords.py --apply /tmp/verdict1.json [/tmp/verdict2.json] < /tmp/kw_probe.json

Keeps a keyword only when a strict majority of its samples are on-topic, lists dropped with reasons, surfaces candidate_channels/candidate_videos from validated samples, and emits groups for Stage 6. Completeness is checked: if the verdict doesn't cover every batch sample, --apply fails and lists the missing indices — re-send just those samples to a fresh validator and merge (cheap models silently drop the tail of long lists; never assume a batch came back whole).

Ask the user on scope, not relevance. Two different questions hide here: relevance ("is this term's match on-topic?" — you judge from samples) and scope ("is this sub-topic part of what the user wants?" — the user's call). When a candidate family is on-relevance but its scope is a genuine judgment — a sibling product, an adjacent model, a broad policy framing — surface 2–4 representative sample snippets and ask in or out. (Live: "Mythos 5 is the sibling model launched alongside Fable 5 — include it? Opus 4.8 is a different model — count it as fallout?") Keep it to the few families that swing the result.

Stage 4 — Refine: ≥3 rounds of boolean composition (the heart)

You research the topic by composing and recomposing boolean queries — narrowing, expanding, and backtracking based on what the corpus shows. This is a search through query-space, not a single pass. Run at least 3 rounds (three is the floor, not a cap — don't stop earlier even if round 1 looks good). Each round:

  1. Compose/recompose. Round 1 is usually the validated OR-union. Later rounds add structure with the moves below.
  2. Measure. Probe changed groups (probe.py --mode sqs) and the union's real coverage — the OR-union as ONE sqs candidate at channel level (keyword sets overlap; per-keyword channels don't sum):
    python3 <SKILL_DIR>/scripts/probe.py --level channel --mode sqs \
      '"retirement planning" | "pension planning" | annuities | 401k'
    
    (A big union over transcript can time out — measure coverage on --fields title,summary, or chunk the union.)
  3. Validate what changed (Stage 3 machinery; 15–20 samples for noise-rate audits — 5 is too few to estimate a noise rate).
  4. Score fitness and write it down: share of on-topic samples, whether noise clusters on one confusable sense, coverage vs the Stage 0 breadth judgment, what the round changed.
  5. Decide the move and record (query, fitness, decision) so you can backtrack. Backtracking is expected, not failure — when a move reduced fitness, discard it, return to the recorded query, try a different axis.
  6. Keep a running validated set across rounds (dedupe by channel_id): carry forward keywords and channels confirmed on-topic even as the query shifts. If the final filter no longer selects some previously-validated channels, surface them separately rather than dropping them silently — losing a strong channel is itself a backtrack signal.

The move set (mechanics + verified numbers in the reference):

  • Narrow a noisy set: add a required dimension (+(marketing | affiliate)), target a field, or exclude the bad sense.
  • Expand a thin one: mine emergent keywords from on-topic samples and the channel validators' adjacent_terms (e.g. "tiktok shop" keeps surfacing amazon/affiliate → probe them). Auto-add only terms that validate on-intent; widening beyond the stated intent (e.g. retirement → general investing) is a scope change — ask the user first. If the topic is genuinely niche and mining runs dry, a small result is the correct answer — say so rather than padding with off-intent terms.
  • Judge marginal value on the residual, not the headline. Before keeping a broad candidate, subtract what the core already catches: probe <candidate> -"<core phrase>" and read those samples (_score floats the relevant docs to the top and hides redundancy). Don't dismiss a small clean residual — ~20–50 genuinely-new on-intent channels earns a group; a report holds many groups at no performance cost.
  • NOT-rescue a polluted term — scoped to its own group. When a term is on-intent but diluted, find the recurring token the off-intent docs share and the on-intent docs don't, and exclude it inside that family's group: ("FIRE movement") -"Free Fire". A whole-filter exclusion over-cuts (live: scoping -openclaw to its arm kept 51 on-topic docs a global exclude lost). Guard against over-exclusion: re-run the on-intent core with vs without — the count should barely move; a material drop means the token is shared — exclude the multi-word phrase instead (-"film festival" cut the Cannes core 4%; bare -film cut 24%).
  • AND-anchor a broad root. A root too broad alone (cannes) is rescued by a mandatory anchor plus a domain OR-qualifier: cannes +lions +(advertising | agency | campaign | "young lions") -"film festival". Non-adjacent AND reaches on-intent docs the phrase "cannes lions" structurally misses (live: +815 distinct channels, ~80% on-intent, surfacing the Young/Future/Media Lions competitions). Judge it on the residual.
  • Field-narrow rescue (title ≫ summary ≫ transcript). transcript is by far the noisiest field, title the cleanest — a term too noisy corpus-wide can be "already very qualified" restricted to titles. Probe it per-field (probe.py --fields title or --fields title,summary); if the title-only samples are clean, keep the group with per-group content_fields in the deliverable ({"text": "cannes +advertising", "content_fields": ["title"]} — a plain two-word group is an adjacent phrase; use + for a true AND) instead of dropping it. Ranking uses the same knowledge: search_channels.py weights title^4,summary^2,transcript^1.
  • Flag stale keywords from the probe's recency fields on the final set — exclude them from the suggested filter or keep with a visible STALE tag, never drop silently; thin niches are surfaced, not hidden.

After round 3 (and every round thereafter) — checkpoint. Present the current validated set (the rendered expression, fitness, what changed), and ask: accept · more rounds · adjust direction. If the user chooses more rounds, interview them about the intent behind the keywords first — which sense to include/exclude, audience/format, must-have sub-topics, brands that should or must not count, reach/language/recency constraints — then fold the answers into the groups and the validators' TOPIC/NOT lines. Autonomous mode (the user opted out of pauses): skip checkpoints, still run ≥3 rounds, stop when fitness stops improving (sane cap ~6 rounds), note that you ran autonomously.

Stage 5 — Materialize the chosen deliverables

Everything here takes the final filter verbatim via --group, so what you deliver is exactly what the filter selects.

Budget reserve — decide it before Stage 4, not after. Materialization and its validation must not be starved by refinement: reserve roughly one query per channel you intend to context-validate (plus ~5 for the searches and triage) before you start spending on refinement rounds. If the budget runs out anyway, degrade EXPLICITLY, never silently: intensity triage (below) is nearly free, and a title-level sense-check of the top results costs zero extra queries — deliver that, clearly labeled "not context-validated", with the full validation offered as a follow-up.

Step 1 — Intensity triage (always, both deliverables): 2–3 ES calls total.

python3 <SKILL_DIR>/scripts/search_channels.py --intensity \
  --group '("cannes lions" | canneslions)' \
  --group 'cannes +lions +(advertising | agency | "young lions") -"film festival"'

One aggregation call measures every channel's relationship to the topic — matching uploads (all-time + recent window) per channel — a second computes each channel's topic share, and enrichment adds names + sponsorability. Tiers:

  • core — the topic is the channel's identity (recurring + share ≥ 50%)
  • recurring — ≥3 matching uploads (tunable --recurring-min): channels that keep returning to the topic. For niche topics this tier IS the sponsorship market — say so plainly: "only N channels are about this topic; the real market is these M recurring-coverage channels."
  • occasional / one_off — count for trend math; usually wrong targets.

Present the tier summary to the user before spending anything further — it's the cheapest honest picture of the topic's channel landscape, and it prioritizes every later per-channel spend.

Step 2a — Trend data (if chosen): the videos + prevalence.

# trend feed: newest matching uploads in the window
python3 <SKILL_DIR>/scripts/search_videos.py --sort date --since 2026-06-01 \
  --size 50 --group '("fable 5" | fable5 | "fable five")'
# biggest matching videos; --distinct-channels for one row per channel
python3 <SKILL_DIR>/scripts/search_videos.py --sort views --distinct-channels \
  --group '("mythos 5" | mythos5) -keto'

Videos come back with title, url, publication date, views/likes/duration, and the channel's name + subscribers. Headline prevalence numbers come free from what you already ran: total matching videos + true distinct channels (the intensity call's distinct_channels). Sense-check a sample of top videos (titles/summaries — Stage 3 machinery); one-off channels COUNT here. For date-sorted feeds prefer --fields title,summary — under a non-relevance sort, incidental transcript mentions surface as prominently as genuinely on-topic uploads. Tell the user when one channel dominates and offer --distinct-channels.

Step 2b — Channel targets (if chosen): rank, then context-validate by tier.

  1. Rank with the final filter (search_channels.py --size 200 --group …): one collapsed ES call ranks channels by their best-matching video (title^4,summary^2,transcript^1) with sponsorability flags. During refinement rounds the coarser --any/--not composition is a quick narrowing lever; the --group form re-runs the delivered filter exactly.
  2. Fetch context for candidates, prioritized by intensity tier — core and recurring first, occasional only if budget remains, one-offs not at all (unless the user asks):
    python3 <SKILL_DIR>/scripts/fetch_context.py --channels 466311,2105 \
      --samples 4 --window 160 investing
    
    Extracts the text window around each keyword occurrence (transcript is caption XML — the script strips/unescapes it client-side).
  3. Classify with the keyword-context-classifier agent (Agent tool, subagent_type: keyword-context-classifier — Haiku-cheap). Give each batch a TOPIC: line, usually a NOT: line, and the indexed evidence. Batch ≈50–100 channels, run batches in parallel. Returns per channel: verdict on_topic|mixed|off_topic, confidence, evidence_quote, adjacent_terms (feed those back to Stage 4). Completeness ritual, non-negotiable: anchor the count in the prompt ("There are exactly 50 channels (indices 0–49). Return exactly 50 objects. The last channel_id is 778812."), and after each batch diff the returned channel_ids against what you sent; re-send missing ones to a fresh agent and merge. Never assume a batch came back whole.
  4. Disposition: the final channel table carries tier × verdict × sponsorability. Keep on_topic AND mixed (labelled, with confidence); exclude only clear off_topic — and surface the excluded list. Rank all, flag don't filter on sponsorability: the user decides what to do with unbookable matches.
Stage 6 — Deliver (build_report.py)

Hand the validated groups to the builder — start from select_keywords.py --apply's groups, adding per-group content_fields / exclude as needed:

echo '{
  "operator":"OR","report_type":"channels","title":"Fable 5 launch",
  "groups":[
    {"text":"fable 5"},
    {"text":"(\"mythos 5\" | mythos5) -keto"},
    {"text":"cannes +advertising","content_fields":["title"]},
    {"text":"dropshipping","exclude":true}
  ]
}' | python3 <SKILL_DIR>/scripts/build_report.py

It prunes union-redundant plain phrases (boolean groups are opaque — never pruned, never prune others), translates boolean-group SQS text into the web app's keyword grammar (uppercase AND/OR/NOT + parens + quoted atoms — raw |/+/- are literal text in a link; */~ are rejected, enumerate variants first), and emits:

  • filter_set — platform shape (keywords, keyword_operator, content_fields, per-group field/exclude maps). Fields are recognized content field names (title, summary, transcript, channel_description, channel_topic_description, …) — unknown names fail loudly.
  • report_link — paste-ready URL that opens the report with the filter applied (no saved record, no credits). The default thing to hand the user.
  • report_config — for tl reports create --config-file <f> --yes to persist a named, shareable report.
  • expression — the whole-filter boolean rendering; record it alongside any saved report so the filter is reproducible (it re-runs via search_channels.py --group).
  • pruned / translated — nothing happens silently.

Show the user: the final keyword groups, the report_link, and the validated channels/videos (with verdicts and sponsorability). Then offer to save via tl-save-report / tl reports create — never save unprompted.

Opt-in: keyword distribution

Only when the user explicitly asks for keyword counts / distribution / "how common is X", the probe's ranked counts ARE the deliverable:

python3 <SKILL_DIR>/scripts/probe.py crypto bitcoin DeFi Web3 "smart contract"

Emits {operator, level, fields, scope, keywords:[{keyword, count, …}]} sorted descending — a superset of the old {operator, keywords:[{keyword, count}]} envelope, so existing consumers (report-builder keyword steps, operator mirroring in tl-save-report) keep working.

Cost

One tl db es query per candidate probe (~1–2 credits each); ~10 candidates ≈ 10–20 credits. search_channels.py and search_videos.py are 2 calls each; --intensity is 2–3 calls TOTAL for the whole tier table regardless of channel count (aggregations) — always run it before spending per-channel. fetch_context.py is 1 call per channel with priced fields — keep --samples small (default 4) and validate the top candidates, not the tail. Haiku validation is cheap by design; batch and parallelize. build_report.py and expand_entities.py are free (no ES). The gated web step adds no credits — a few WebSearch/WebFetch calls inside the resolver's own context; it fires only on post-cutoff / renamed / trend / jargon-dense topics. Run tl describe show db for live rates; preview with tl db es … --pricing.

Self-check before you finish

  1. You stated the deliverable, operator, scope (YouTube + longform default), and your breadth judgment, and they match the intent. Both the path (quick/deep) and the deliverable (trend data / channel targets / both) were the user's explicit choice — named in the request or answered when you asked in ONE combined question with rough credit costs; never a silent default or an assumed channel list.
  2. You expanded deeply — facets, every entity's full family (company → product → model → codename → sibling), tokenization variants (fable5 and fable 5) — and mined probe samples for terms you missed. No bare over-broad root or generic collocation survived. For a post-cutoff / renamed / trend / jargon-dense topic you ran the gated keyword-entity-resolver lookup, and every web name passed validation like any other candidate.
  3. Every surviving keyword was validated against the verbatim intent — you read the channels count and distinct-channel samples, not the inflated doc total. Genuine scope calls went to the user with sample snippets.
  4. On the deep path, ≥3 refinement rounds ran, each reporting its query, fitness, and move (narrow / expand / backtrack); broad terms were judged on their residual; rescues (NOT scoped in-group, AND-anchor, field-narrow) were tried before dropping a real term; exclusions were verified not to over-cut the core. On the quick path, you said so in the result. You narrated every stage with its running credit spend, and the Stage 5 budget reserve was set aside before refinement began.
  5. Coverage was measured on the union and matches the intended breadth; any scope-widening was user-confirmed (never silent drift).
  6. Intensity triage ran before any per-channel spend, and its tier summary was shown to the user. For the channel-targets deliverable, the final table carries tier × verdict × sponsorability: context validation ran with the completeness ritual (every batch diffed by channel_id, missing items re-sent), prioritized core + recurring; only clear off_topic channels excluded, and the exclusion surfaced; all ranked, none filtered for being unbookable. If the topic has few/no core channels you said so and framed the recurring tier as the market — no padding, no near-empty list passed off as the answer. If budget forced a degraded validation you labeled it "not context-validated" explicitly. For the trend deliverable you materialized the matching videos (search_videos.py, sort/window matched to the ask) with prevalence numbers and sense-checked a sample.
  7. Redundant terms pruned and reported; stale terms flagged (excluded or visibly tagged), thin niches surfaced — nothing dropped silently.
  8. The deliverable includes the filter set and a working report_link (group text in app grammar — no raw |/+/-/*/~) and the validated channels/videos and the recorded expression.
  9. Checkpoint honored per the chosen path: the deep path paused after round 3 with the intent interview on request — or the user's autonomy preference was honored and noted. Nothing was saved without confirmation.
  10. If the user requests a chart, create it as an SVG graphic.
1---
2name: tl-keyword-research
3tl-blurb: build & validate keyword filter sets + context-validated channels
4description: |
5 Turn a topic into a *validated* content filter and the channels it selects.
6 Invoke whenever the user wants to find videos or channels by what they are
7 about — topics, concepts, niches, not IDs or names: "a group/set of keywords
8 for <topic>", "keywords that represent <topic> content", "find content/channels
9 about <topic>", "find investing channels" — or when you'd otherwise
10 hand-compose a `tl db es` content search. It expands the topic into candidate
11 keywords (with a gated web lookup for post-cutoff entities), probes
12 Elasticsearch for each (counts + samples), validates matches against the
13 user's stated intent, refines a boolean filter over ≥3 rounds, and delivers
14 a keyword-group filter set + a clickable report link + the results the user
15 chose: trend data at the video level (matching uploads + prevalence), or
16 channel targets classified by topic intensity (core / recurring / one-off)
17 and context-validated with cheap agents, or both. It ASKS which deliverable
18 and which run mode (quick single pass vs deep ≥3-round refinement) the user
19 wants whenever the request doesn't say — never a silent default;
20 `autonomous` / `--auto` means deep + both deliverables with every pause
21 skipped. Keyword-distribution output (counts per keyword) is OPT-IN — only
22 when the user explicitly asks for "keyword counts / distribution / how
23 common is X". Also invoke for HELP asks about this skill — "help", "how
24 does keyword research work", "what are my options", "describe this skill" —
25 answered free from the built-in guide, no queries run.
26---
27 
28# tl-keyword-research — topic → validated filter set + the results you choose
29 
30Turn a fuzzy topic into a **precise, validated content filter** over our data —
31and whichever results the user actually wants from it: the trend picture at
32the video level, channel targets classified by how much they cover the topic,
33or both. The value is not brainstorming synonyms (anyone can do that, and a
34free YouTube search does it too) — it's writing real **Boolean queries over
35the right fields**, then **validating that the matches are actually on-topic**
36against our corpus. That combination is what our data makes possible and a
37plain keyword list does not.
38 
39The deliverable has one mandatory layer and two optional layers **the user
40chooses between** (never assume — see *Choosing the path & deliverable*):
41 
421. **The topic itself (always).** A filter set of keyword groups + a
43 **clickable report link** that opens the platform with the filter applied
44 (+ a persist config) — `build_report.py`. The topic and its prevalence
45 (matching videos, distinct channels) is a complete deliverable on its own —
46 a trend journalist writing "how big is this on YouTube" needs exactly this
47 and may not want a channel list at all.
482. **Trend data (video level).** The matching uploads — sortable by
49 date/views, windowed — via `search_videos.py`. A creator who covered the
50 topic once counts here.
513. **Channel targets.** Channels classified by their **relationship to the
52 topic** — core / recurring / occasional / one-off (`search_channels.py
53 --intensity`) — then ranked, sense-checked, and flagged for sponsorability.
54 Never assume "channels about the topic" means only channels *entirely*
55 about it: for niche topics there may be almost none, and the real
56 sponsorship market is channels that return to the topic repeatedly.
57 
58The **canonical artifact is the keyword-group filter set**: each group is a
59self-contained boolean query (an exclusion can be scoped to its own arm —
60`("mythos 5" | mythos5) -keto` — which a flat CNF cannot express). The rendered
61boolean **expression** is recorded alongside for provenance and re-runs
62verbatim via `search_channels.py --group` / `search_videos.py --group`.
63 
64> Read `references/elasticsearch-content-search.md` before writing queries — it
65> covers the article-vs-channel doc types, the content fields (and ES's
66> **legacy** channel field names), `simple_query_string` Boolean syntax,
67> tokenization, the report-link keyword grammar, and why we return `_source`
68> samples (the CLI drops ES `highlight`).
69 
70`<SKILL_DIR>` below is this skill's directory (the one holding `SKILL.md`).
71 
72## When to invoke / skip
73 
74Invoke when the user wants **videos or channels by content** (topics, concepts,
75niches), gives seed keywords or an NL phrase to widen into a content filter, or
76you're about to hand-compose a `tl db es` content search — delegate here first.
77 
78Skip when:
79- The user has explicit channel/brand IDs or names → `tl channels find` / `tl brands find`.
80- Intent maps cleanly to a curated recommender tag (e.g. "Cooking channels") →
81 `tl recommender top-channels "<tag>"`. Don't re-discover curated tags by text match.
82 
83## Help mode — explain yourself on request, for free
84 
85When the user asks for help, what this skill does, how it works, what the
86options/parameters are, or to "describe the skill" ("help", "how does this
87work", "what can you do here", "what are my options", "explain the flow"):
88**run nothing** — no queries, no scripts, zero credits. Read
89`references/help.md` and present it clearly, sized to what they asked (the
90full guide for "explain how this works"; just the relevant slice for "what
91sorting options are there?"). Close by offering to start a run. Mid-run
92option questions ("what does deep mean?", "what's the recurring tier?") get
93the same treatment — answer from the guide, then continue where you paused.
94 
95## Choosing the path & deliverable — the user picks, never a silent default
96 
97Two choices shape the run, and both belong to the USER:
98 
99**Path** (how hard to refine):
100- **Quick** — one expand → probe → validate → deliver pass, no refinement
101 rounds (≈ 10–20 credits). Even here, tokenization variants, entity-family
102 expansion, and sample validation are mandatory — speed never excuses a
103 shallow synonym list.
104- **Deep** — the full pipeline: ≥3 refinement rounds (Stage 4) plus
105 materialization (Stage 5) (≈ 60–120 credits depending on how much gets
106 materialized and validated).
107 
108**Deliverable** (what comes back besides the filter + link, which are always
109included):
110- **Trend data** — the matching videos + prevalence numbers (video level).
111- **Channel targets** — channels tiered by topic intensity, sense-checked,
112 sponsorability-flagged.
113- **Both.**
114 
115How to pick:
116 
1171. **The request names it → run it, no questions.** "quick"/"fast"/"just a
118 starting set" → quick; "deep"/"thorough"/"take your time" → deep. "trend",
119 "prevalence", "how big is this", "who's talking about it right now" →
120 trend data; "channels to sponsor", "targets", "channels for [client]" →
121 channel targets.
1222. **Otherwise, ask before starting — ONE combined question** covering
123 whichever of the two is unstated, with the trade in a line each (include
124 the rough credit costs above). Don't silently default: guessing quick
125 hides what the skill can do; guessing deep spends credits the user never
126 asked for; and assuming the user wants a channel list when they wanted
127 trend data (or vice versa) answers a question they didn't ask.
1283. **Exception:** `autonomous` / `--auto` / "don't stop to ask" with no named
129 path/deliverable means the **deep path, both deliverables, every pause
130 skipped** — the user has asked for zero questions.
131 
132**Autonomy within the deep path:** at any point the user can say run
133autonomously / without pausing (or invoke with `autonomous` / `--auto`) —
134keep refining without checkpoints per Stage 4. The preference holds for the
135rest of the session unless revoked.
136 
137## The pipeline (you orchestrate; scripts + cheap agents do the work)
138 
139| Stage | What happens | Tooling |
140|---|---|---|
141| 0 Set up | intent, deliverable, operator, breadth judgment, scope; gated web entity resolution | `keyword-entity-resolver` agent + `expand_entities.py` |
142| 1 Expand | deep, creative candidate generation | you |
143| 2 Probe | counts + samples per candidate | `probe.py` |
144| 3 Validate keywords | on-intent check per keyword; scope calls to the user | inline / `select_keywords.py` + `keyword-relevance-validator` agent |
145| 4 Refine | ≥3 rounds of boolean composition, fitness, backtracking | `probe.py --mode sqs`, `search_channels.py` |
146| 5 Materialize | intensity triage, then trend data and/or channel targets, validated | `search_channels.py --intensity` · `search_videos.py --group` · `search_channels.py --group` → `fetch_context.py` → `keyword-context-classifier` agent |
147| 6 Deliver | filter set + report link + the chosen deliverables; offer to save | `build_report.py`, `tl-save-report` |
148 
149**Narrate the run.** The user must always know what's happening: one line at
150every stage transition — what you're doing, why, and the approximate credits
151spent so far (*"Stage 2 — probing 18 candidates, one query each (≈25 credits
152so far)"*). Never run a silent stage, and surface every drop/prune/failure as
153it happens, not just at the end.
154 
155**Spend tokens as carefully as credits.** Deterministic work belongs in the
156scripts (zero model tokens — tokenization variants, tier math, pruning, link
157building are all scripted). Judgment calls go to the **cheapest capable
158model** via the bundled agents: `keyword-relevance-validator` and
159`keyword-context-classifier` run on Haiku; only the web-reading
160`keyword-entity-resolver` runs on Sonnet. Don't do in your own context what a
161script or a Haiku batch can do.
162 
163### Stage 0 — Set up: intent, deliverable, operator, breadth, scope
164 
165Keep the user's own sentence **verbatim as the intent** — it's the yardstick
166every validation judges against. Then state your assumptions so the user can
167correct them:
168 
169- **Deliverable** — trend data (videos), channel targets, or both: the user's
170 choice from *Choosing the path & deliverable*. Either way, the research
171 runs at **topic level** (probe `--level topic` — videos are where the
172 keywords live); channel-doc probes (`--level channel`) remain a tool for
173 channel-fit checks and the existence probe below. Two calibration facts to
174 establish early:
175 - **Does a channel-identity version of this topic even exist?** One cheap
176 channel-doc probe on the core term (`probe.py --level channel --samples 3
177 "<core>"`) answers it: a tiny distinct-channel count means essentially no
178 channel is *about* this topic — say so, and set expectations that the
179 channel deliverable will be built from **recurring-coverage** channels
180 (Stage 5's intensity tiers), not identity matches. Never return a
181 near-empty "core" list as if it were the whole answer, and never pad it.
182 - **A one-off mention is not a target.** For trend math every matching
183 upload counts; for sponsorship targeting a channel that mentioned the
184 topic once usually doesn't. The intensity tiers keep these apart —
185 don't collapse them.
186- **Operator.** Default `OR` (union of a niche's facets); `AND` only for a true
187 intersection ("both X and Y", composite nouns). Under AND, keep candidates
188 *inside* the intersection — don't broaden each component independently.
189- **Breadth judgment — say it back.** There is no universal right result size.
190 "Underwater basket weaving" returning a few dozen channels is a complete
191 answer; "basketball" returning a few dozen is a miss. If the topic reads
192 niche, offer to broaden; if broad, offer to narrow to a sub-area. This
193 judgment is what Stage 4's coverage check measures against. The probe's
194 per-keyword **`channels`** count (distinct channels) is the measuring stick.
195- **Scope — state it.** Everything is scoped to **YouTube uploads** and, at
196 video level, **longform** by default; offer `--content-type all` (or
197 `short`/`live`) if the user wants more.
198 
199**Gated web entity resolution — only when memory can't.** Expansion is the one
200input drawn purely from model knowledge, so it fails where knowledge does (the
201classic miss: expanding a launch to the company name and never generating the
202product, its version, or its **sibling**). Trigger a web lookup ONLY when the
203topic names an entity that **postdates the knowledge cutoff**, was **recently
204renamed/rebranded**, or is a **live trend / insider-jargon-dense niche** you
205cannot enumerate from memory. For a topic you know cold (cooking, retirement
206planning, basketball), skip it. When the gate fires, say so and why, then
207delegate to the **`keyword-entity-resolver`** sub-agent (Agent tool,
208`subagent_type: keyword-entity-resolver`) so the noisy page text never enters
209your context — it returns a compact JSON of *names* only:
210 
211```
212topic: <the topic>
213intent: <one sentence — what the user actually wants>
214level: topic|channel
215known: ["...", "..."] # your own candidates so far — it fills the gaps
216```
217 
218Turn its reply into probe-ready candidates with `expand_entities.py` — it
219generates the tokenization spelling variants, folds each family into one
220boolean group, pairs rename aliases into `(old | new)`, and dedupes against
221`--existing`:
222 
223```bash
224python3 <SKILL_DIR>/scripts/expand_entities.py --probe-batch \
225 --existing "cannes lions" "advertising awards" < resolver.json \
226 | python3 <SKILL_DIR>/scripts/probe.py --samples 5
227```
228 
229Non-batch mode also returns `collisions` (a polluter watch-list for the Stage 4
230NOT-rescue) and `hashtags` (for optional `hashtags` field targeting). **Web is
231for entity NAMES, never synonyms / breadth / validation** — breadth is the
232distinct-`channels` count over our index, relevance is judged only from this
233index's samples, and every web-sourced name earns its place through the same
234probe + validation as any other candidate (no benefit of the doubt; a
235hallucinated name probes to `count: 0` and drops).
236 
237### Stage 1 — Expand: go wide, go deep, go creative
238 
239A shallow synonym list is the #1 failure mode. Coverage is won or lost here.
240 
241- **Decompose the topic into facets and expand each one.** An event/launch:
242 lead-up → the launch → product specifics → **sibling & ecosystem products** →
243 reactions → fallout → regulatory. A niche: sub-areas, tools, personas, jargon.
244- **Expand every named entity into its whole family** — company, product line,
245 model/version, codename, **sibling shipped alongside**. (Live miss: searching
246 `anthropic` for the Claude Fable 5 launch missed `fable 5` (713 ch),
247 `claude mythos` (620 ch — the sibling), and `mythos 5` (433 ch) — most of the
248 topic.)
249- **Generate tokenization variants for every name/number.** The index tokenizes
250 `fable5`, `fable 5`/`fable-5`, and `fable five` as *different terms that miss
251 each other's documents* — probe each spelling (solid / spaced / spelled-out /
252 hashtag-handle) as its own candidate. No stemming either: expand
253 inflections/plurals yourself (`invest`/`investing`/`investments` are distinct).
254- **Candidates can be boolean groups, not just phrases** — a self-contained
255 `simple_query_string` like `("fable 5" | fable5 | "claude fable")`, or a
256 family carrying its own scoped de-noising `("mythos 5" | mythos5) -keto`.
257- **Scale the candidate count to the topic.** A one-line niche: ~8–15. A
258 multi-facet event: **30–50**, covering every family and its spelling variants.
259 
260Rules:
261- **Never add the bare over-broad root** — single word *or* generic
262 collocation. For "TikTok Shop" don't add `tiktok`; `supply chain risk` alone
263 matches its own broad domain unless entity-qualified.
264- **Mine the data, don't only brainstorm.** After the first probe, read the
265 on-topic samples for recurring terms you didn't think of and re-probe them.
266 This data-driven discovery is what a synonym list can't do.
267- **Brands**: when the topic IS an entity/event, full entity-family expansion is
268 mandatory (see above). For a generic topic, don't drift into naming specific
269 brands unless the seeds contain one (then adjacent brands in the category are
270 fine). **No specific channel names** (`tl channels find`). No random padding.
271 
272### Stage 2 — Probe (`probe.py`)
273 
274One ES query per candidate → counts + samples for validation:
275 
276```bash
277# topic level (videos): phrase candidates
278python3 <SKILL_DIR>/scripts/probe.py --level topic \
279 "tiktok shop" "selling on tiktok" "tiktok affiliate"
280 
281# Boolean candidates via simple_query_string (default_operator=and is set for you)
282python3 <SKILL_DIR>/scripts/probe.py --mode sqs \
283 '"tiktok shop" +(marketing|affiliate|ecommerce)' '("mythos 5" | mythos5) -keto'
284 
285# channel level (whole channels); JSON array on stdin also works
286python3 <SKILL_DIR>/scripts/probe.py --level channel "cooking" "baking"
287```
288 
289Output: `{operator, level, fields, scope, keywords:[{keyword, count, documents,
290channels, subsumed_by, samples, …recency}], dropped, failed, recency}`.
291 
292- **Two counts, always.** `documents` = raw match total; **`channels`** =
293 DISTINCT channels reached (cardinality agg). Channel docs are duplicated
294 across quarterly indexes, so at channel level `documents` is meaningless —
295 `count` (the ranking headline) is documents at topic level, channels at
296 channel level. Judge niche size by **`channels`**.
297- **Samples are collapsed to distinct channels**, so one prolific channel can't
298 flood the slots — that's what makes validation meaningful. Topic samples:
299 `title`/`summary` (+ `channel_id`, `category`, `url`); channel samples:
300 `name`/`topic` (+ `channel_description`, `channel_id`).
301- **Scope is always-on** (YouTube `format` 4; longform at topic level unless
302 `--content-type all|short|live`) and echoed under `scope` — tell the user.
303- **Recency rides the same query** (no extra credits): topic level
304 `recent_documents`/`recent_channels` over `--recency-months` (default 12);
305 channel level `active_channels` (`posts_per_90_days > 0` — channel docs have
306 no date). Each keyword carries `stale` (absolute-first rule, so high-volume
307 evergreens are never mislabeled) and `thin` (below floor but proportionally
308 alive). Annotations only — nothing is dropped here.
309- **`failed` lists candidates whose probe errored/timed out** — retry those
310 individually; they are not dropped keywords.
311- `subsumed_by` is informational (a broader phrase is present); pruning happens
312 after validation, in `build_report.py`, or the broad root would always win.
313- `--since/--until` are topic-only (channel docs have no publication date).
314 
315SQS power for candidates (`--mode sqs`): trailing `*` catches inflections,
316`~1` absorbs typos, `"a b"~2` catches near-phrases — **research-only**: the
317report filter set can't hold `*`/`~` (see the reference), so enumerate the
318surviving variants before delivery.
319 
320### Stage 3 — Validate keywords against the intent
321 
322This fixes "the word is there but the topic isn't" — the step that separates
323this skill from a free YouTube search.
324 
325**Inline (≤ ~15 candidates):** read each keyword's `samples` against the
326verbatim intent. Drop off-intent keywords, `count: 0`, and redundant
327`subsumed_by` duplicates.
328 
329**At scale (the `keyword-relevance-validator` sub-agent):**
330 
331```bash
332python3 <SKILL_DIR>/scripts/probe.py "tiktok shop" "selling on tiktok" "tiktok" > /tmp/kw_probe.json
333python3 <SKILL_DIR>/scripts/select_keywords.py --emit-batch < /tmp/kw_probe.json > /tmp/kw_batch.json
334```
335 
336Send the batch (prepending one line — `intent: <one sentence>`) to the
337`keyword-relevance-validator` agent (Agent tool), save the strict reply
338`[{i,relevant}]`, optionally run a second pass for a majority vote, then:
339 
340```bash
341python3 <SKILL_DIR>/scripts/select_keywords.py --apply /tmp/verdict1.json [/tmp/verdict2.json] < /tmp/kw_probe.json
342```
343 
344Keeps a keyword only when a strict majority of its samples are on-topic, lists
345`dropped` with reasons, surfaces `candidate_channels`/`candidate_videos` from
346validated samples, and emits `groups` for Stage 6. **Completeness is checked**:
347if the verdict doesn't cover every batch sample, `--apply` fails and lists the
348missing indices — re-send just those samples to a fresh validator and merge
349(cheap models silently drop the tail of long lists; never assume a batch came
350back whole).
351 
352**Ask the user on scope, not relevance.** Two different questions hide here:
353*relevance* ("is this term's match on-topic?" — you judge from samples) and
354*scope* ("is this sub-topic part of what the user wants?" — the user's call).
355When a candidate family is on-relevance but its scope is a genuine judgment —
356a sibling product, an adjacent model, a broad policy framing — surface 2–4
357representative sample snippets and ask in or out. (Live: *"Mythos 5 is the
358sibling model launched alongside Fable 5 — include it? Opus 4.8 is a different
359model — count it as fallout?"*) Keep it to the few families that swing the
360result.
361 
362### Stage 4 — Refine: ≥3 rounds of boolean composition (the heart)
363 
364You **research the topic by composing and recomposing boolean queries** —
365narrowing, expanding, and backtracking based on what the corpus shows. This is
366a search through query-space, not a single pass. **Run at least 3 rounds**
367(three is the floor, not a cap — don't stop earlier even if round 1 looks
368good). Each round:
369 
3701. **Compose/recompose.** Round 1 is usually the validated OR-union. Later
371 rounds add structure with the moves below.
3722. **Measure.** Probe changed groups (`probe.py --mode sqs`) and the union's
373 real coverage — the OR-union as ONE sqs candidate at channel level (keyword
374 sets overlap; per-keyword `channels` don't sum):
375 ```bash
376 python3 <SKILL_DIR>/scripts/probe.py --level channel --mode sqs \
377 '"retirement planning" | "pension planning" | annuities | 401k'
378 ```
379 (A big union over `transcript` can time out — measure coverage on
380 `--fields title,summary`, or chunk the union.)
3813. **Validate** what changed (Stage 3 machinery; 15–20 samples for noise-rate
382 audits — 5 is too few to estimate a noise rate).
3834. **Score fitness** and write it down: share of on-topic samples, whether
384 noise clusters on one confusable sense, coverage vs the Stage 0 breadth
385 judgment, what the round changed.
3865. **Decide the move** and record (query, fitness, decision) so you can
387 backtrack. **Backtracking is expected, not failure** — when a move reduced
388 fitness, discard it, return to the recorded query, try a different axis.
3896. **Keep a running validated set** across rounds (dedupe by `channel_id`):
390 carry forward keywords and channels confirmed on-topic even as the query
391 shifts. If the final filter no longer selects some previously-validated
392 channels, surface them separately rather than dropping them silently —
393 losing a strong channel is itself a backtrack signal.
394 
395**The move set** (mechanics + verified numbers in the reference):
396 
397- **Narrow** a noisy set: add a required dimension (`+(marketing | affiliate)`),
398 target a field, or exclude the bad sense.
399- **Expand** a thin one: mine emergent keywords from on-topic samples and the
400 channel validators' `adjacent_terms` (e.g. "tiktok shop" keeps surfacing
401 `amazon`/`affiliate` → probe them). Auto-add only terms that validate
402 on-intent; widening beyond the stated intent (e.g. retirement → general
403 investing) is a **scope change — ask the user first**. If the topic is
404 genuinely niche and mining runs dry, a small result is the correct answer —
405 say so rather than padding with off-intent terms.
406- **Judge marginal value on the residual, not the headline.** Before keeping a
407 broad candidate, subtract what the core already catches: probe
408 `<candidate> -"<core phrase>"` and read *those* samples (`_score` floats the
409 relevant docs to the top and hides redundancy). **Don't dismiss a small clean
410 residual** — ~20–50 genuinely-new on-intent channels earns a group; a report
411 holds many groups at no performance cost.
412- **NOT-rescue a polluted term — scoped to its own group.** When a term is
413 on-intent but diluted, find the recurring token the off-intent docs share and
414 the on-intent docs don't, and exclude it *inside that family's group*:
415 `("FIRE movement") -"Free Fire"`. A whole-filter exclusion over-cuts (live:
416 scoping `-openclaw` to its arm kept 51 on-topic docs a global exclude lost).
417 **Guard against over-exclusion**: re-run the on-intent core with vs without —
418 the count should barely move; a material drop means the token is shared —
419 exclude the multi-word **phrase** instead (`-"film festival"` cut the Cannes
420 core 4%; bare `-film` cut 24%).
421- **AND-anchor a broad root.** A root too broad alone (`cannes`) is rescued by
422 a mandatory anchor plus a domain OR-qualifier:
423 `cannes +lions +(advertising | agency | campaign | "young lions") -"film festival"`.
424 Non-adjacent AND reaches on-intent docs the phrase `"cannes lions"`
425 structurally misses (live: +815 distinct channels, ~80% on-intent, surfacing
426 the Young/Future/Media Lions competitions). Judge it on the residual.
427- **Field-narrow rescue (title ≫ summary ≫ transcript).** `transcript` is by
428 far the noisiest field, `title` the cleanest — a term too noisy corpus-wide
429 can be "already very qualified" restricted to titles. Probe it per-field
430 (`probe.py --fields title` or `--fields title,summary`); if the title-only
431 samples are clean, keep the group **with per-group `content_fields`** in the
432 deliverable (`{"text": "cannes +advertising", "content_fields": ["title"]}` — a plain
433 two-word group is an adjacent *phrase*; use `+` for a true AND)
434 instead of dropping it. Ranking uses the same knowledge: `search_channels.py`
435 weights `title^4,summary^2,transcript^1`.
436- **Flag stale keywords** from the probe's recency fields on the final set —
437 exclude them from the suggested filter or keep with a visible STALE tag,
438 never drop silently; `thin` niches are surfaced, not hidden.
439 
440**After round 3 (and every round thereafter) — checkpoint.** Present the
441current validated set (the rendered expression, fitness, what changed), and
442ask: accept · more rounds · adjust direction. If the user chooses more
443rounds, **interview them about the intent behind the keywords** first —
444which sense to include/exclude, audience/format, must-have sub-topics,
445brands that should or must not count, reach/language/recency constraints —
446then fold the answers into the groups and the validators' TOPIC/NOT lines.
447**Autonomous mode** (the user opted out of pauses): skip checkpoints, still
448run ≥3 rounds, stop when fitness stops improving (sane cap ~6 rounds), note
449that you ran autonomously.
450 
451### Stage 5 — Materialize the chosen deliverables
452 
453Everything here takes the final filter verbatim via `--group`, so what you
454deliver is exactly what the filter selects.
455 
456**Budget reserve — decide it before Stage 4, not after.** Materialization and
457its validation must not be starved by refinement: reserve roughly one query
458per channel you intend to context-validate (plus ~5 for the searches and
459triage) before you start spending on refinement rounds. If the budget runs
460out anyway, degrade EXPLICITLY, never silently: intensity triage (below) is
461nearly free, and a title-level sense-check of the top results costs zero
462extra queries — deliver that, clearly labeled **"not context-validated"**,
463with the full validation offered as a follow-up.
464 
465**Step 1 — Intensity triage (always, both deliverables): 2–3 ES calls total.**
466 
467```bash
468python3 <SKILL_DIR>/scripts/search_channels.py --intensity \
469 --group '("cannes lions" | canneslions)' \
470 --group 'cannes +lions +(advertising | agency | "young lions") -"film festival"'
471```
472 
473One aggregation call measures every channel's **relationship to the topic**
474— matching uploads (all-time + recent window) per channel — a second computes
475each channel's topic share, and enrichment adds names + sponsorability. Tiers:
476 
477- **core** — the topic is the channel's identity (recurring + share ≥ 50%)
478- **recurring** — ≥3 matching uploads (tunable `--recurring-min`): channels
479 that keep returning to the topic. **For niche topics this tier IS the
480 sponsorship market** — say so plainly: *"only N channels are about this
481 topic; the real market is these M recurring-coverage channels."*
482- **occasional** / **one_off** — count for trend math; usually wrong targets.
483 
484Present the tier summary to the user before spending anything further —
485it's the cheapest honest picture of the topic's channel landscape, and it
486prioritizes every later per-channel spend.
487 
488**Step 2a — Trend data (if chosen): the videos + prevalence.**
489 
490```bash
491# trend feed: newest matching uploads in the window
492python3 <SKILL_DIR>/scripts/search_videos.py --sort date --since 2026-06-01 \
493 --size 50 --group '("fable 5" | fable5 | "fable five")'
494# biggest matching videos; --distinct-channels for one row per channel
495python3 <SKILL_DIR>/scripts/search_videos.py --sort views --distinct-channels \
496 --group '("mythos 5" | mythos5) -keto'
497```
498 
499Videos come back with title, url, publication date, views/likes/duration, and
500the channel's name + subscribers. Headline prevalence numbers come free from
501what you already ran: total matching videos + true distinct channels (the
502intensity call's `distinct_channels`). Sense-check a sample of top videos
503(titles/summaries — Stage 3 machinery); one-off channels COUNT here. For
504date-sorted feeds prefer `--fields title,summary` — under a non-relevance
505sort, incidental transcript mentions surface as prominently as genuinely
506on-topic uploads. Tell the user when one channel dominates and offer
507`--distinct-channels`.
508 
509**Step 2b — Channel targets (if chosen): rank, then context-validate by tier.**
510 
5111. **Rank** with the final filter (`search_channels.py --size 200 --group …`):
512 one collapsed ES call ranks channels by their best-matching video
513 (`title^4,summary^2,transcript^1`) with sponsorability flags. During
514 refinement rounds the coarser `--any/--not` composition is a quick
515 narrowing lever; the `--group` form re-runs the delivered filter exactly.
5162. **Fetch context** for candidates, prioritized by intensity tier — core and
517 recurring first, occasional only if budget remains, one-offs not at all
518 (unless the user asks):
519 ```bash
520 python3 <SKILL_DIR>/scripts/fetch_context.py --channels 466311,2105 \
521 --samples 4 --window 160 investing
522 ```
523 Extracts the text window around each keyword occurrence (transcript is
524 caption XML — the script strips/unescapes it client-side).
5253. **Classify** with the **`keyword-context-classifier`** agent (Agent tool,
526 `subagent_type: keyword-context-classifier` — Haiku-cheap). Give each batch
527 a `TOPIC:` line, usually a `NOT:` line, and the indexed evidence. Batch
528 ≈50–100 channels, run batches in parallel. Returns per channel:
529 `verdict on_topic|mixed|off_topic`, `confidence`, `evidence_quote`,
530 `adjacent_terms` (feed those back to Stage 4).
531 **Completeness ritual, non-negotiable:** anchor the count in the prompt
532 (*"There are exactly 50 channels (indices 0–49). Return exactly 50 objects.
533 The last channel_id is 778812."*), and after each batch **diff the returned
534 `channel_id`s against what you sent; re-send missing ones to a fresh agent
535 and merge.** Never assume a batch came back whole.
5364. **Disposition:** the final channel table carries **tier × verdict ×
537 sponsorability**. Keep `on_topic` AND `mixed` (labelled, with confidence);
538 exclude only clear `off_topic` — and surface the excluded list. Rank all,
539 **flag don't filter** on sponsorability: the user decides what to do with
540 unbookable matches.
541 
542### Stage 6 — Deliver (`build_report.py`)
543 
544Hand the validated groups to the builder — start from `select_keywords.py
545--apply`'s `groups`, adding per-group `content_fields` / `exclude` as needed:
546 
547```bash
548echo '{
549 "operator":"OR","report_type":"channels","title":"Fable 5 launch",
550 "groups":[
551 {"text":"fable 5"},
552 {"text":"(\"mythos 5\" | mythos5) -keto"},
553 {"text":"cannes +advertising","content_fields":["title"]},
554 {"text":"dropshipping","exclude":true}
555 ]
556}' | python3 <SKILL_DIR>/scripts/build_report.py
557```
558 
559It prunes union-redundant plain phrases (boolean groups are opaque — never
560pruned, never prune others), **translates boolean-group SQS text into the web
561app's keyword grammar** (uppercase `AND`/`OR`/`NOT` + parens + quoted atoms —
562raw `|`/`+`/`-` are literal text in a link; `*`/`~` are rejected, enumerate
563variants first), and emits:
564 
565- `filter_set` — platform shape (`keywords`, `keyword_operator`,
566 `content_fields`, per-group field/exclude maps). Fields are **recognized
567 content field names** (`title`, `summary`, `transcript`, `channel_description`,
568 `channel_topic_description`, …) — unknown names fail loudly.
569- `report_link` — paste-ready URL that opens the report with the filter applied
570 (no saved record, no credits). **The default thing to hand the user.**
571- `report_config` — for `tl reports create --config-file <f> --yes` to persist
572 a named, shareable report.
573- `expression` — the whole-filter boolean rendering; **record it alongside any
574 saved report** so the filter is reproducible (it re-runs via
575 `search_channels.py --group`).
576- `pruned` / `translated` — nothing happens silently.
577 
578Show the user: the final keyword groups, the `report_link`, and the validated
579channels/videos (with verdicts and sponsorability). Then **offer** to save via
580`tl-save-report` / `tl reports create` — never save unprompted.
581 
582## Opt-in: keyword distribution
583 
584Only when the user explicitly asks for keyword counts / distribution / "how
585common is X", the probe's ranked counts ARE the deliverable:
586 
587```bash
588python3 <SKILL_DIR>/scripts/probe.py crypto bitcoin DeFi Web3 "smart contract"
589```
590 
591Emits `{operator, level, fields, scope, keywords:[{keyword, count, …}]}`
592sorted descending — a superset of the old `{operator, keywords:[{keyword,
593count}]}` envelope, so existing consumers (report-builder keyword steps,
594operator mirroring in `tl-save-report`) keep working.
595 
596## Cost
597 
598One `tl db es` query per candidate probe (~1–2 credits each); ~10 candidates ≈
59910–20 credits. `search_channels.py` and `search_videos.py` are 2 calls each;
600`--intensity` is 2–3 calls TOTAL for the whole tier table regardless of
601channel count (aggregations) — always run it before spending per-channel.
602`fetch_context.py` is 1 call per channel with priced fields — keep `--samples`
603small (default 4) and validate the top candidates, not the tail. Haiku validation is cheap by design;
604batch and parallelize. `build_report.py` and `expand_entities.py` are free (no
605ES). The gated web step adds no credits — a few WebSearch/WebFetch calls inside
606the resolver's own context; it fires only on post-cutoff / renamed / trend /
607jargon-dense topics. Run `tl describe show db` for live rates; preview with
608`tl db es … --pricing`.
609 
610## Self-check before you finish
611 
6121. You stated the deliverable, operator, **scope** (YouTube + longform
613 default), and your **breadth judgment**, and they match the intent. Both
614 the **path** (quick/deep) and the **deliverable** (trend data / channel
615 targets / both) were the user's explicit choice — named in the request or
616 answered when you asked in ONE combined question with rough credit costs;
617 never a silent default or an assumed channel list.
6182. You expanded **deeply** — facets, every entity's full family (company →
619 product → model → codename → **sibling**), **tokenization variants**
620 (`fable5` *and* `fable 5`) — and mined probe samples for terms you missed.
621 No bare over-broad root or generic collocation survived. For a post-cutoff /
622 renamed / trend / jargon-dense topic you ran the gated
623 `keyword-entity-resolver` lookup, and every web name passed validation like
624 any other candidate.
6253. Every surviving keyword was validated against the verbatim intent — you read
626 the **`channels`** count and distinct-channel samples, not the inflated doc
627 total. Genuine **scope** calls went to the user with sample snippets.
6284. On the deep path, **≥3 refinement rounds** ran, each reporting its query,
629 fitness, and move (narrow / expand / backtrack); broad terms were judged
630 on their **residual**; rescues (NOT scoped in-group, AND-anchor,
631 field-narrow) were tried before dropping a real term; exclusions were
632 verified not to over-cut the core. On the quick path, you said so in the
633 result. You **narrated every stage** with its running credit spend, and
634 the Stage 5 budget reserve was set aside before refinement began.
6355. Coverage was measured on the union and matches the intended breadth; any
636 scope-widening was user-confirmed (never silent drift).
6376. Intensity triage ran before any per-channel spend, and its tier summary
638 was shown to the user. For the channel-targets deliverable, the final
639 table carries **tier × verdict × sponsorability**: context validation ran
640 with the completeness ritual (every batch diffed by `channel_id`, missing
641 items re-sent), prioritized core + recurring; only clear `off_topic`
642 channels excluded, and the exclusion surfaced; all ranked, none filtered
643 for being unbookable. If the topic has few/no core channels you said so
644 and framed the recurring tier as the market — no padding, no near-empty
645 list passed off as the answer. If budget forced a degraded validation you
646 labeled it "not context-validated" explicitly. For the trend deliverable
647 you materialized the matching videos (`search_videos.py`, sort/window
648 matched to the ask) with prevalence numbers and sense-checked a sample.
6497. Redundant terms pruned and reported; **stale** terms flagged (excluded or
650 visibly tagged), `thin` niches surfaced — nothing dropped silently.
6518. The deliverable includes the filter set **and** a working `report_link`
652 (group text in app grammar — no raw `|`/`+`/`-`/`*`/`~`) **and** the
653 validated channels/videos **and** the recorded `expression`.
6549. Checkpoint honored per the chosen path: the deep path paused after round 3
655 with the intent interview on request — or the user's autonomy preference
656 was honored and noted. Nothing was saved without confirmation.
65710. If the user requests a chart, create it as an SVG graphic.
658 

Discussion