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
- Run the line below. It pulls the whole folder into
~/.claude/skills/tl-keyword-research, including the files SKILL.md points to. - Describe your job in plain words. Claude Code follows the skill from there.
npx degit ThoughtLeaders-io/thoughtleaders-cli/skills/tl-keyword-research#main ~/.claude/skills/tl-keyword-researchFor 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)
- On this page open ⋯ → Download .md.
- Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
- Pick the file and Save. Claude shows the name and description and runs a security scan.
- Check the skill is switched on.
- Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
- ChatGPT: make a Project and paste it into Instructions.
- 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.
Paste into Claude, ChatGPT or Cursor.
Source of tl-keyword-research — topic → validated filter set + the results you choose
Show the full text658 lines
| name | tl-blurb | description |
|---|---|---|
| tl-keyword-research | build & 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):
- 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. - 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. - 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.mdbefore writing queries — it covers the article-vs-channel doc types, the content fields (and ES's legacy channel field names),simple_query_stringBoolean syntax, tokenization, the report-link keyword grammar, and why we return_sourcesamples (the CLI drops EShighlight).
<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:
- 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.
- 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.
- 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.
- Does a channel-identity version of this topic even exist? One cheap
channel-doc probe on the core term (
- Operator. Default
OR(union of a niche's facets);ANDonly 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
channelscount (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(orshort/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
anthropicfor the Claude Fable 5 launch missedfable 5(713 ch),claude mythos(620 ch — the sibling), andmythos 5(433 ch) — most of the topic.) - Generate tokenization variants for every name/number. The index tokenizes
fable5,fable 5/fable-5, andfable fiveas 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/investmentsare distinct). - Candidates can be boolean groups, not just phrases — a self-contained
simple_query_stringlike("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 riskalone 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 leveldocumentsis meaningless —count(the ranking headline) is documents at topic level, channels at channel level. Judge niche size bychannels. - 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
format4; longform at topic level unless--content-type all|short|live) and echoed underscope— tell the user. - Recency rides the same query (no extra credits): topic level
recent_documents/recent_channelsover--recency-months(default 12); channel levelactive_channels(posts_per_90_days > 0— channel docs have no date). Each keyword carriesstale(absolute-first rule, so high-volume evergreens are never mislabeled) andthin(below floor but proportionally alive). Annotations only — nothing is dropped here. failedlists candidates whose probe errored/timed out — retry those individually; they are not dropped keywords.subsumed_byis informational (a broader phrase is present); pruning happens after validation, inbuild_report.py, or the broad root would always win.--since/--untilare 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:
- Compose/recompose. Round 1 is usually the validated OR-union. Later rounds add structure with the moves below.
- 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-keywordchannelsdon't sum):
(A big union overpython3 <SKILL_DIR>/scripts/probe.py --level channel --mode sqs \ '"retirement planning" | "pension planning" | annuities | 401k'transcriptcan time out — measure coverage on--fields title,summary, or chunk the union.) - Validate what changed (Stage 3 machinery; 15–20 samples for noise-rate audits — 5 is too few to estimate a noise rate).
- 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.
- 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.
- 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 surfacingamazon/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 (_scorefloats 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-openclawto 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-filmcut 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).
transcriptis by far the noisiest field,titlethe cleanest — a term too noisy corpus-wide can be "already very qualified" restricted to titles. Probe it per-field (probe.py --fields titleor--fields title,summary); if the title-only samples are clean, keep the group with per-groupcontent_fieldsin 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.pyweightstitle^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;
thinniches 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.
- 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/--notcomposition is a quick narrowing lever; the--groupform re-runs the delivered filter exactly. - 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):
Extracts the text window around each keyword occurrence (transcript is caption XML — the script strips/unescapes it client-side).python3 <SKILL_DIR>/scripts/fetch_context.py --channels 466311,2105 \ --samples 4 --window 160 investing - Classify with the
keyword-context-classifieragent (Agent tool,subagent_type: keyword-context-classifier— Haiku-cheap). Give each batch aTOPIC:line, usually aNOT: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 returnedchannel_ids against what you sent; re-send missing ones to a fresh agent and merge. Never assume a batch came back whole. - Disposition: the final channel table carries tier × verdict ×
sponsorability. Keep
on_topicANDmixed(labelled, with confidence); exclude only clearoff_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— fortl reports create --config-file <f> --yesto 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 viasearch_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
- 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.
- You expanded deeply — facets, every entity's full family (company →
product → model → codename → sibling), tokenization variants
(
fable5andfable 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 gatedkeyword-entity-resolverlookup, and every web name passed validation like any other candidate. - Every surviving keyword was validated against the verbatim intent — you read
the
channelscount and distinct-channel samples, not the inflated doc total. Genuine scope calls went to the user with sample snippets. - 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.
- Coverage was measured on the union and matches the intended breadth; any scope-widening was user-confirmed (never silent drift).
- 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 clearoff_topicchannels 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. - Redundant terms pruned and reported; stale terms flagged (excluded or
visibly tagged),
thinniches surfaced — nothing dropped silently. - 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 recordedexpression. - 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.
- If the user requests a chart, create it as an SVG graphic.
| 1 | |
| 2 | name tl-keyword-research |
| 3 | tl-blurb build & validate keyword filter sets + context-validated channels |
| 4 | description | |
| 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 | |
| 30 | Turn a fuzzy topic into a **precise, validated content filter** over our data — |
| 31 | and whichever results the user actually wants from it: the trend picture at |
| 32 | the video level, channel targets classified by how much they cover the topic, |
| 33 | or both. The value is not brainstorming synonyms (anyone can do that, and a |
| 34 | free YouTube search does it too) — it's writing real **Boolean queries over |
| 35 | the right fields**, then **validating that the matches are actually on-topic** |
| 36 | against our corpus. That combination is what our data makes possible and a |
| 37 | plain keyword list does not. |
| 38 | |
| 39 | The deliverable has one mandatory layer and two optional layers **the user |
| 40 | chooses between** (never assume — see *Choosing the path & deliverable*): |
| 41 | |
| 42 | **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. |
| 48 | **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. |
| 51 | **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 | |
| 58 | The **canonical artifact is the keyword-group filter set**: each group is a |
| 59 | self-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 |
| 61 | boolean **expression** is recorded alongside for provenance and re-runs |
| 62 | verbatim 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 | |
| 74 | Invoke when the user wants **videos or channels by content** (topics, concepts, |
| 75 | niches), gives seed keywords or an NL phrase to widen into a content filter, or |
| 76 | you're about to hand-compose a `tl db es` content search — delegate here first. |
| 77 | |
| 78 | Skip 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 | |
| 85 | When the user asks for help, what this skill does, how it works, what the |
| 86 | options/parameters are, or to "describe the skill" ("help", "how does this |
| 87 | work", "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 |
| 90 | full guide for "explain how this works"; just the relevant slice for "what |
| 91 | sorting options are there?"). Close by offering to start a run. Mid-run |
| 92 | option questions ("what does deep mean?", "what's the recurring tier?") get |
| 93 | the 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 | |
| 97 | Two 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 |
| 109 | included): |
| 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 | |
| 115 | How to pick: |
| 116 | |
| 117 | **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. |
| 122 | **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. |
| 128 | **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 |
| 133 | autonomously / without pausing (or invoke with `autonomous` / `--auto`) — |
| 134 | keep refining without checkpoints per Stage 4. The preference holds for the |
| 135 | rest 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 |
| 150 | every stage transition — what you're doing, why, and the approximate credits |
| 151 | spent so far (*"Stage 2 — probing 18 candidates, one query each (≈25 credits |
| 152 | so far)"*). Never run a silent stage, and surface every drop/prune/failure as |
| 153 | it happens, not just at the end. |
| 154 | |
| 155 | **Spend tokens as carefully as credits.** Deterministic work belongs in the |
| 156 | scripts (zero model tokens — tokenization variants, tier math, pruning, link |
| 157 | building are all scripted). Judgment calls go to the **cheapest capable |
| 158 | model** 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 |
| 161 | script or a Haiku batch can do. |
| 162 | |
| 163 | ### Stage 0 — Set up: intent, deliverable, operator, breadth, scope |
| 164 | |
| 165 | Keep the user's own sentence **verbatim as the intent** — it's the yardstick |
| 166 | every validation judges against. Then state your assumptions so the user can |
| 167 | correct 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 |
| 200 | input drawn purely from model knowledge, so it fails where knowledge does (the |
| 201 | classic miss: expanding a launch to the company name and never generating the |
| 202 | product, its version, or its **sibling**). Trigger a web lookup ONLY when the |
| 203 | topic names an entity that **postdates the knowledge cutoff**, was **recently |
| 204 | renamed/rebranded**, or is a **live trend / insider-jargon-dense niche** you |
| 205 | cannot enumerate from memory. For a topic you know cold (cooking, retirement |
| 206 | planning, basketball), skip it. When the gate fires, say so and why, then |
| 207 | delegate to the **`keyword-entity-resolver`** sub-agent (Agent tool, |
| 208 | `subagent_type: keyword-entity-resolver`) so the noisy page text never enters |
| 209 | your context — it returns a compact JSON of *names* only: |
| 210 | |
| 211 | |
| 212 | topic: <the topic> |
| 213 | intent: <one sentence — what the user actually wants> |
| 214 | level: topic|channel |
| 215 | known: ["...", "..."] # your own candidates so far — it fills the gaps |
| 216 | |
| 217 | |
| 218 | Turn its reply into probe-ready candidates with `expand_entities.py` — it |
| 219 | generates the tokenization spelling variants, folds each family into one |
| 220 | boolean group, pairs rename aliases into `(old | new)`, and dedupes against |
| 221 | `--existing`: |
| 222 | |
| 223 | |
| 224 | python3 <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 | |
| 229 | Non-batch mode also returns `collisions` (a polluter watch-list for the Stage 4 |
| 230 | NOT-rescue) and `hashtags` (for optional `hashtags` field targeting). **Web is |
| 231 | for entity NAMES, never synonyms / breadth / validation** — breadth is the |
| 232 | distinct-`channels` count over our index, relevance is judged only from this |
| 233 | index's samples, and every web-sourced name earns its place through the same |
| 234 | probe + validation as any other candidate (no benefit of the doubt; a |
| 235 | hallucinated name probes to `count: 0` and drops). |
| 236 | |
| 237 | ### Stage 1 — Expand: go wide, go deep, go creative |
| 238 | |
| 239 | A 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 | |
| 260 | Rules: |
| 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 | |
| 274 | One ES query per candidate → counts + samples for validation: |
| 275 | |
| 276 | |
| 277 | # topic level (videos): phrase candidates |
| 278 | python3 <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) |
| 282 | python3 <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 |
| 286 | python3 <SKILL_DIR>/scripts/probe.py --level channel "cooking" "baking" |
| 287 | |
| 288 | |
| 289 | Output: `{operator, level, fields, scope, keywords:[{keyword, count, documents, |
| 290 | channels, 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 | |
| 315 | SQS power for candidates (`--mode sqs`): trailing `*` catches inflections, |
| 316 | `~1` absorbs typos, `"a b"~2` catches near-phrases — **research-only**: the |
| 317 | report filter set can't hold `*`/`~` (see the reference), so enumerate the |
| 318 | surviving variants before delivery. |
| 319 | |
| 320 | ### Stage 3 — Validate keywords against the intent |
| 321 | |
| 322 | This fixes "the word is there but the topic isn't" — the step that separates |
| 323 | this skill from a free YouTube search. |
| 324 | |
| 325 | **Inline (≤ ~15 candidates):** read each keyword's `samples` against the |
| 326 | verbatim 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 | |
| 332 | python3 <SKILL_DIR>/scripts/probe.py "tiktok shop" "selling on tiktok" "tiktok" > /tmp/kw_probe.json |
| 333 | python3 <SKILL_DIR>/scripts/select_keywords.py --emit-batch < /tmp/kw_probe.json > /tmp/kw_batch.json |
| 334 | |
| 335 | |
| 336 | Send 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 | |
| 341 | python3 <SKILL_DIR>/scripts/select_keywords.py --apply /tmp/verdict1.json [/tmp/verdict2.json] < /tmp/kw_probe.json |
| 342 | |
| 343 | |
| 344 | Keeps a keyword only when a strict majority of its samples are on-topic, lists |
| 345 | `dropped` with reasons, surfaces `candidate_channels`/`candidate_videos` from |
| 346 | validated samples, and emits `groups` for Stage 6. **Completeness is checked**: |
| 347 | if the verdict doesn't cover every batch sample, `--apply` fails and lists the |
| 348 | missing 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 |
| 350 | back 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). |
| 355 | When a candidate family is on-relevance but its scope is a genuine judgment — |
| 356 | a sibling product, an adjacent model, a broad policy framing — surface 2–4 |
| 357 | representative sample snippets and ask in or out. (Live: *"Mythos 5 is the |
| 358 | sibling model launched alongside Fable 5 — include it? Opus 4.8 is a different |
| 359 | model — count it as fallout?"*) Keep it to the few families that swing the |
| 360 | result. |
| 361 | |
| 362 | ### Stage 4 — Refine: ≥3 rounds of boolean composition (the heart) |
| 363 | |
| 364 | You **research the topic by composing and recomposing boolean queries** — |
| 365 | narrowing, expanding, and backtracking based on what the corpus shows. This is |
| 366 | a 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 |
| 368 | good). Each round: |
| 369 | |
| 370 | **Compose/recompose.** Round 1 is usually the validated OR-union. Later |
| 371 | rounds add structure with the moves below. |
| 372 | **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 | |
| 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.) |
| 381 | **Validate** what changed (Stage 3 machinery; 15–20 samples for noise-rate |
| 382 | audits — 5 is too few to estimate a noise rate). |
| 383 | **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. |
| 386 | **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. |
| 389 | **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 |
| 441 | current validated set (the rendered expression, fitness, what changed), and |
| 442 | ask: accept · more rounds · adjust direction. If the user chooses more |
| 443 | rounds, **interview them about the intent behind the keywords** first — |
| 444 | which sense to include/exclude, audience/format, must-have sub-topics, |
| 445 | brands that should or must not count, reach/language/recency constraints — |
| 446 | then fold the answers into the groups and the validators' TOPIC/NOT lines. |
| 447 | **Autonomous mode** (the user opted out of pauses): skip checkpoints, still |
| 448 | run ≥3 rounds, stop when fitness stops improving (sane cap ~6 rounds), note |
| 449 | that you ran autonomously. |
| 450 | |
| 451 | ### Stage 5 — Materialize the chosen deliverables |
| 452 | |
| 453 | Everything here takes the final filter verbatim via `--group`, so what you |
| 454 | deliver is exactly what the filter selects. |
| 455 | |
| 456 | **Budget reserve — decide it before Stage 4, not after.** Materialization and |
| 457 | its validation must not be starved by refinement: reserve roughly one query |
| 458 | per channel you intend to context-validate (plus ~5 for the searches and |
| 459 | triage) before you start spending on refinement rounds. If the budget runs |
| 460 | out anyway, degrade EXPLICITLY, never silently: intensity triage (below) is |
| 461 | nearly free, and a title-level sense-check of the top results costs zero |
| 462 | extra queries — deliver that, clearly labeled **"not context-validated"**, |
| 463 | with the full validation offered as a follow-up. |
| 464 | |
| 465 | **Step 1 — Intensity triage (always, both deliverables): 2–3 ES calls total.** |
| 466 | |
| 467 | |
| 468 | python3 <SKILL_DIR>/scripts/search_channels.py --intensity \ |
| 469 | --group '("cannes lions" | canneslions)' \ |
| 470 | --group 'cannes +lions +(advertising | agency | "young lions") -"film festival"' |
| 471 | |
| 472 | |
| 473 | One aggregation call measures every channel's **relationship to the topic** |
| 474 | — matching uploads (all-time + recent window) per channel — a second computes |
| 475 | each 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 | |
| 484 | Present the tier summary to the user before spending anything further — |
| 485 | it's the cheapest honest picture of the topic's channel landscape, and it |
| 486 | prioritizes every later per-channel spend. |
| 487 | |
| 488 | **Step 2a — Trend data (if chosen): the videos + prevalence.** |
| 489 | |
| 490 | |
| 491 | # trend feed: newest matching uploads in the window |
| 492 | python3 <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 |
| 495 | python3 <SKILL_DIR>/scripts/search_videos.py --sort views --distinct-channels \ |
| 496 | --group '("mythos 5" | mythos5) -keto' |
| 497 | |
| 498 | |
| 499 | Videos come back with title, url, publication date, views/likes/duration, and |
| 500 | the channel's name + subscribers. Headline prevalence numbers come free from |
| 501 | what you already ran: total matching videos + true distinct channels (the |
| 502 | intensity call's `distinct_channels`). Sense-check a sample of top videos |
| 503 | (titles/summaries — Stage 3 machinery); one-off channels COUNT here. For |
| 504 | date-sorted feeds prefer `--fields title,summary` — under a non-relevance |
| 505 | sort, incidental transcript mentions surface as prominently as genuinely |
| 506 | on-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 | |
| 511 | **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. |
| 516 | **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 | |
| 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). |
| 525 | **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. |
| 536 | **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 | |
| 544 | Hand 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 | |
| 548 | echo '{ |
| 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 | |
| 559 | It prunes union-redundant plain phrases (boolean groups are opaque — never |
| 560 | pruned, never prune others), **translates boolean-group SQS text into the web |
| 561 | app's keyword grammar** (uppercase `AND`/`OR`/`NOT` + parens + quoted atoms — |
| 562 | raw `|`/`+`/`-` are literal text in a link; `*`/`~` are rejected, enumerate |
| 563 | variants 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 | |
| 578 | Show the user: the final keyword groups, the `report_link`, and the validated |
| 579 | channels/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 | |
| 584 | Only when the user explicitly asks for keyword counts / distribution / "how |
| 585 | common is X", the probe's ranked counts ARE the deliverable: |
| 586 | |
| 587 | |
| 588 | python3 <SKILL_DIR>/scripts/probe.py crypto bitcoin DeFi Web3 "smart contract" |
| 589 | |
| 590 | |
| 591 | Emits `{operator, level, fields, scope, keywords:[{keyword, count, …}]}` |
| 592 | sorted descending — a superset of the old `{operator, keywords:[{keyword, |
| 593 | count}]}` envelope, so existing consumers (report-builder keyword steps, |
| 594 | operator mirroring in `tl-save-report`) keep working. |
| 595 | |
| 596 | ## Cost |
| 597 | |
| 598 | One `tl db es` query per candidate probe (~1–2 credits each); ~10 candidates ≈ |
| 599 | 10–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 |
| 601 | channel count (aggregations) — always run it before spending per-channel. |
| 602 | `fetch_context.py` is 1 call per channel with priced fields — keep `--samples` |
| 603 | small (default 4) and validate the top candidates, not the tail. Haiku validation is cheap by design; |
| 604 | batch and parallelize. `build_report.py` and `expand_entities.py` are free (no |
| 605 | ES). The gated web step adds no credits — a few WebSearch/WebFetch calls inside |
| 606 | the resolver's own context; it fires only on post-cutoff / renamed / trend / |
| 607 | jargon-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 | |
| 612 | 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. |
| 618 | 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. |
| 625 | 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. |
| 628 | 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. |
| 635 | Coverage was measured on the union and matches the intended breadth; any |
| 636 | scope-widening was user-confirmed (never silent drift). |
| 637 | 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. |
| 649 | Redundant terms pruned and reported; **stale** terms flagged (excluded or |
| 650 | visibly tagged), `thin` niches surfaced — nothing dropped silently. |
| 651 | 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`. |
| 654 | 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. |
| 657 | If the user requests a chart, create it as an SVG graphic. |
| 658 |
Discussion
Browse more free Claude skills.