| name | media-opportunity-finder |
| platforms | ["claude-code"] |
| description | Surface paid placement opportunities — newsletter ad slots and podcast sponsorships — for Tiger Data marketing campaigns. Use this skill when the user asks to find newsletter placements, find podcasts to sponsor, find media opportunities, find sponsorship opportunities, scout newsletters or podcasts, or asks 'where should we advertise [topic]' or 'find dev newsletters [topic].' Operates in two modes: brief-driven (for a specific campaign) and browse (category sweep). Returns a tight chat summary plus a structured markdown doc on disk. Do NOT use for writing ad copy (use newsletter-ad-writer), post-campaign performance evaluation (use newsletter-ad-review), event sponsorship strategy (use event-brief-planner), or earned/guest opportunities, KOL sourcing, and video sponsorships (all v2+, not yet supported). |
| references | ["product-marketing-context","brand-voice-guide","no-fly-list"] |
Media Opportunity Finder
Surface paid placement opportunities for Tiger Data marketing campaigns. v1 covers
newsletter ad slots and podcast sponsorships. The skill discovers candidates via
directory-aware web search, ranks them into three tiers with cited metadata, and
writes both a tight chat summary and a structured markdown doc.
⚠ Privacy callout
Generated docs contain campaign strategy, ICP details, audience-fit rationale,
budget signals, and (when paste-in is used) engagement history. The
marketing-skills repo is public — outputs must never be committed.
- The skill writes outputs only to a privacy-safe path (see Step 0c).
output/ and state/ directories are gitignored and remain on the user's machine.
- Never copy generated outputs into the public repo or other public-facing locations.
When to use this skill
- The user is scoping a campaign and asks where to advertise (newsletter slots, podcast sponsorships)
- The user asks for a category sweep ("what dev newsletters exist in databases?")
- The user wants ranked, cited recommendations with cost signals where available
When NOT to use this skill
- Writing ad copy →
newsletter-ad-writer
- Post-campaign performance evaluation →
newsletter-ad-review
- Event sponsorship strategy →
event-brief-planner
- Earned / guest opportunities, KOL sourcing, video sponsorships — not supported yet (v2+)
- Booking or contracting placements — out of scope; the skill produces research only
Step 0a: Pre-flight — Tiger Den references
Read REFERENCES.md from the plugin root and run the standard pre-flight check.
Call list_marketing_references() to verify Tiger Den is reachable. If it fails
or the tool is not found, STOP — do not continue. Follow the error handling in
REFERENCES.md, including the /doctor fallback and manual reconnect instructions:
- Cowork: "Go to Settings (gear icon) → Connectors → find the Tiger Den connector → click Connect and sign in. Then start a new session and try again."
- Claude Code: "Run
claude mcp add -s user --transport http tiger_den https://den.tigerdata.com/api/mcp/mcp in a separate terminal, then restart Claude Code."
Once Tiger Den is confirmed, fetch the no-fly list:
get_marketing_reference(slug: "no-fly-list")
Load these names as a hard constraint: never recommend a publication, podcast,
host, or operator that is on the no-fly list. Never use a no-fly list customer as
an audience-fit proof point or reach-comparator. If a no-fly list name appears in
search results, omit it from all outputs.
Then fetch context references in one call:
get_marketing_context(slugs: ["product-marketing-context", "brand-voice-guide"])
Internalize:
- product-marketing-context governs Tiger Data ICP, audience, and positioning. Default audience for brief mode when the user omits it.
- brand-voice-guide governs terminology and voice constraints (used for the chat summary and doc prose, not for the structured per-result blocks).
Finally, load bundled references from the skill directory:
references/source-directories.md — directories to steer searches through
references/industry-benchmarks.md — CPM ranges for cost triangulation
Step 0b: Pre-flight — Tooling check
This skill requires web_search and web_fetch. If web_search is unavailable,
inform the user and STOP — the skill cannot function without it. If web_fetch
is unavailable, STOP — sponsor-page sweeps and adjacency signals require
fetching pub-side pages.
If web_fetch fails on a specific URL during execution, log the failure in the
methodology footer of the output doc and continue with other sources.
Step 0c: Pre-flight — Output path resolution
Resolve the output path BEFORE any discovery work begins. Generated docs contain
proprietary campaign data and the marketing-skills repo is public.
Resolution priority order:
-
User-specified private path. If the user provided an explicit output path
in the invocation (e.g., "save outputs to /Users/me/private-marketing/"),
use that path. Verify it is outside any public git repo (no .git directory in
the path or any parent up to a reasonable depth, OR the path is inside a private
repo). If verification fails, stop and ask the user to confirm.
-
Skill-local output/ directory (default). Path:
<skill-root>/output/. The bundled .gitignore ensures contents never enter
version control. Create the directory if it does not exist.
-
Refuse to write. If the resolved path lands inside a tracked location of a
public repo (no .gitignore coverage matches the file), STOP and ask the user
for a private path. Never silently write into a tracked public location.
Once resolved, save the absolute path to a local variable for use in Step 6 and Step 7.
Step 1: Mode detection and intake
Examine the user's invocation prompt to detect mode:
- Brief mode — the input contains a specific campaign or topic (e.g.,
"find newsletters for our agentic AI vector store launch," "scout podcasts for
the pgvector AI engineer audience"). Has a clear what and ideally a clear
who.
- Browse mode — the input is a category sweep (e.g., "find dev newsletters in
databases," "what Postgres podcasts exist?"). No specific campaign attached.
If the input is ambiguous (e.g., just "find me some podcasts"), ask ONE clarifying
question to determine mode. Do not present a form. Example:
"Are you scoping placements for a specific campaign, or doing a broader category sweep? If campaign, share the topic and audience. If sweep, share the category."
Brief mode intake (if missing required fields)
Conversational, one question at a time. Do not present a form.
Required fields:
- Campaign / topic context — free text. What we're promoting and the angle.
- Target audience — defaults to Tiger Data ICP from
product-marketing-context if omitted. Confirm with the user before defaulting.
Optional fields (skill prompts only if user has given partial context):
- Budget tier —
small / mid / enterprise. Used to filter cost-range matching.
- Format preferences —
newsletter_primary, newsletter_classified, podcast_pre_roll, podcast_mid_roll, podcast_post_roll. Multiple allowed.
- Engaged-sources list — paste-in. The user can include pubs/podcasts already booked, evaluated, or rejected. Format: free text or one item per line. Default behavior is no dedup — surface all candidates to test discovery quality. Only include this prompt if the user has not already mentioned it.
Browse mode intake (if missing required fields)
Required fields:
- Category — free text. Examples: "dev newsletters in databases," "Postgres podcasts," "AI engineering podcasts."
Optional fields:
- Engaged-sources list — same as brief mode.
Search budget
The skill applies a hard cap of 30 combined web_search + web_fetch calls
per invocation by default. The user can override by including phrases like "use a
budget of 50" or "deeper sweep" in the prompt — accept any number ≥10 and ≤200.
Step 2: Discovery — directory-aware search
Open references/source-directories.md. For each candidate type (newsletter,
podcast, or both based on input), execute searches in this order:
2a. Type-specific directory queries
For newsletters, run searches like:
site:cooperpress.com {topic} — surfaces relevant Cooperpress newsletters
dev newsletter {topic} OR {audience} — broad query
site:substack.com {topic} newsletter — Substack discoveries
site:beehiiv.com {topic} — Beehiiv discoveries
"{topic}" newsletter sponsor
"{topic}" newsletter advertise
For podcasts, run searches like:
site:listennotes.com {topic} — ListenNotes index
site:podchaser.com {topic} — Podchaser
site:podcasts.apple.com technology {topic} — Apple chart
"{topic}" podcast sponsor
"{topic}" podcast advertise
top {topic} podcasts {current_year} — chart-style queries
Substitute {topic} with terms from the brief (e.g., "Postgres", "AI engineering",
"vector search", "MLOps"). Substitute {audience} for browse-mode category terms.
For each search, examine the top 5–10 results. Capture:
- Publication / podcast name
- URL
- Brief description (from the search snippet or the directory listing)
2b. Sponsor-page sweep (per candidate)
For each named candidate, attempt to find the sponsor or media-kit page. Try these
URL patterns in order:
{candidate-domain}/sponsor
{candidate-domain}/advertise
{candidate-domain}/partner or /partners
{candidate-domain}/media-kit
{candidate-domain}/work-with-us
{candidate-domain}/sponsorship
Use web_fetch to pull the page. Extract:
- Audience description
- Subscriber count or download count (verify the source — is it stated by the publication itself?)
- Cadence / frequency
- Slot types available
- Ad rates (if listed)
- Recent advertiser logos or sponsor lists (for adjacency signal)
- Contact URL
If none of the URL patterns work, check the publication's About page and Contact
page. If still no sponsor info, the candidate's cost_status will be request_required
or unknown (see Step 5).
2c. Adjacency signals (sponsor overlap)
For 2–3 known-good baseline pubs/podcasts in the campaign's space (use the seed
list in source-directories.md), fetch recent issues or episodes via web_fetch
and extract sponsor mentions. Pubs/podcasts that have hosted sponsors from
adjacent companies (databases, dev infra, dev tools, AI infrastructure) score
higher on sponsor_adjacency. This signal helps surface candidates the user
hasn't named.
2d. Search-budget tracking
Maintain a running count of web_search + web_fetch calls. When 80% of the
budget is consumed, decide:
- If discovery has yielded ≥10 candidates with
audience_fit: high, finalize
discovery and proceed to ranking.
- If fewer, prompt the user once: "Search budget at 80%. Continue with current
candidates, extend by 30 more calls, or stop now?" Default to "continue with
current candidates" if the user does not respond within the same turn.
When the cap is reached, stop discovery, finish ranking whatever candidates have
been gathered, and explicitly note in the methodology footer: "Search budget
exhausted at N calls. Not fully vetted: [list]."
Step 3: Ranking and tiering
For each candidate, score against these signals (priority order):
- Audience fit (high / medium / low) — does the publication's stated audience match the brief's ICP/topic? Cite the URL of the audience description.
- Reach (verified / estimated / undisclosed) — only count when disclosed by the publication or a credible third-party source. Cite the source URL. Estimated only when there is corroborating evidence (e.g., ListenNotes estimate that aligns with the publication's general size signals); otherwise undisclosed.
- Format match — does the pub sell the ad type the brief calls for? If brief omits format, all formats are considered.
- Brand alignment (high / medium / low) — technical depth, developer-respectful tone, host quality, content audited rather than spammy. LLM judgment with cited examples (link to a representative recent issue or episode).
- Activity / recency (active / declining / dormant) — last published in last 30 days, healthy cadence, not declining. Cite the source (archive page, RSS feed, recent post).
- Sponsor adjacency — list of relevant adjacent sponsors observed.
Tier assignment
Apply this rubric:
- Tier 1 — Audience fit
high, brand alignment high, reach disclosed (any tier) OR reach undisclosed but other signals are strong, activity active. These are recommendations the campaign owner should pursue.
- Tier 2 — Audience fit
medium to high, brand alignment medium to high, mixed signals (e.g., great audience but smaller reach, or great reach but tone slightly off-brand). Worth considering, not actively recommended.
- Tier 3 — Audience fit
low to medium, OR brand alignment low, OR activity declining/dormant. FYI / long shot. Surfaces during search for completeness.
Force tiering — never dump candidates without judgment.
Engaged-sources dedup (only if user provided a list)
For each candidate, match against the user's engaged_sources paste-in list:
- Name fuzzy match — case-insensitive substring + token overlap. "Postgres Weekly" matches "PostgresWeekly" or "Postgres weekly newsletter."
- URL exact match — after normalizing scheme and trailing slash.
If a candidate matches:
- Set
dedup_status: already_engaged.
- Demote one tier: Tier 1 → Tier 2, Tier 2 → Tier 3, Tier 3 stays at Tier 3.
- Keep visible by default. The user can request "exclude already-engaged" mode for a cleaner output.
If no engaged_sources was provided, all results have dedup_status: new. This is the v1 default — discovery quality is the calibration target.
Step 4: Cost handling
For each candidate, populate the cost block with one of four cost_status values:
published — Rate card found on a public sponsor page. Capture range or exact value, cite the source URL.
request_required — Pub has a contact or sponsor page but doesn't publish rates. Capture the contact URL. This is also the required fallback when public evidence (reach, format, vertical) is too thin to triangulate a credible estimate. Better to point to the contact page than to fabricate confidence.
estimated — No published rate, but the skill has credible public evidence (verified or reasonably-estimated reach, confirmed format/slot type, identified vertical) sufficient to anchor against references/industry-benchmarks.md. Required: populate estimate_basis with the exact benchmark row used (e.g., "dev newsletter, 50–100K subs, primary slot") AND estimate_evidence_url with the URL of the reach-evidence page.
unknown — The skill cannot even confirm whether the pub sells ads. Demote the candidate to Tier 3 unless other signals are unusually strong.
Anti-hallucination guardrails (NON-NEGOTIABLE)
- Every quantitative claim (subscribers, downloads, ad rate, frequency, last-publish date) must cite a URL it came from. No URL = field marked
undisclosed or estimated, never a guessed number presented as fact.
- Never fabricate rate cards. If a sponsor page exists but no rate is listed,
cost_status: request_required with the contact URL.
- "Not disclosed" is a valid — and preferred — output over a guess.
- For
cost_status: estimated, the estimate_basis and estimate_evidence_url fields are REQUIRED. An estimated rate without a benchmark row and reach-evidence URL is invalid — fall back to request_required instead.
Step 5: Output — chat summary
Render a tight summary in chat. Format:
Searched {N_directories} newsletter directories + {M_directories} podcast directories.
Found {N_newsletters} newsletter + {M_podcasts} podcast candidates for "{brief or category}" — ranked into 3 tiers.
Tier 1 ({count}) — strong fit, recommend pursuing:
1. {name} — {one-line reach + cost summary}
2. {name} — {one-line reach + cost summary}
...
Tier 2 ({count}) — worth considering. Say 'expand tier 2' for details.
Tier 3 ({count}) — FYI / long shot. Say 'expand tier 3' for details.
Full doc: {output_path}/media-opportunity-finder-{date}-{slug}.md
Suggested next steps:
- {action 1, e.g., "Request rate cards from Tier 1 picks 2, 4 (cost_status: request_required)"}
- {action 2, e.g., "Campaign owner review on Tier 1 — N/M are published rates so budgetable today"}
- {action 3, e.g., "K Tier 2 candidates have strong sponsor adjacency to {company list} — worth a closer look"}
Constraints on the chat summary:
- Top 3–5 Tier 1 picks listed inline. If Tier 1 has more than 5, list the top 5 and say "(+N more in Tier 1 in full doc)".
- Tier 2 and Tier 3 are counted but not listed inline. The full lists are in the doc.
- The "expand tier 2" / "expand tier 3" follow-up is handled in Step 7 (read from disk, no re-search).
- Suggested next steps must reference real candidates and real
cost_status values from the run — not generic advice.
Step 6: Output — markdown doc on disk
Write the full ranked list to a markdown file at the resolved output path.
Filename: media-opportunity-finder-{YYYY-MM-DD}-{slug}.md
Where {slug} is a kebab-case version of the brief topic or category (e.g., agentic-ai-vector-store, postgres-podcasts).
Doc structure
---
run_date: {YYYY-MM-DD}
mode: {brief|browse}
brief_or_category: "{user-provided text}"
audience: "{from brief or product-marketing-context default}"
budget_tier: {small|mid|enterprise|unspecified}
format_preferences: [{format_list_or_none}]
engaged_sources_provided: {true|false}
tiger_den_references_loaded: [product-marketing-context, brand-voice-guide, no-fly-list]
search_budget_used: {N}/{cap}
---
# Media Opportunity Finder — {brief or category}
Run on {date} in {mode} mode.
## Tier 1 — strong fit, recommend pursuing
{N candidates, each rendered as a per-result block}
## Tier 2 — worth considering
{N candidates, each rendered as a per-result block}
## Tier 3 — FYI / long shot
{N candidates, each rendered as a per-result block}
## Methodology
- Directories searched: {list from references/source-directories.md actually used}
- Search queries executed: {list of queries run}
- Search budget consumed: {N}/{cap}
- Coverage gaps / failures: {anything not fully vetted, web_fetch failures, etc.}
- Adjacency seed list used: {pubs/podcasts whose sponsors were sampled}
Per-result block
Each candidate is rendered as a YAML-ish block under its tier section. The placeholder values below show field shape only — every field in real output must be populated from cited sources per the anti-hallucination guardrails.
- name: {publication or podcast name}
url: {homepage URL}
type: {newsletter | podcast}
publisher: {publisher name, e.g., Cooperpress, Substack, indie}
engagement_type: paid
tier: {1 | 2 | 3}
rationale: {1–2 sentences on why this fits the brief}
audience:
description: {audience description}
fit_score: {high | medium | low}
fit_source_url: {URL where the audience description came from}
reach:
value: {number or null}
unit: {subscribers | monthly_downloads | mau}
verification: {verified | estimated | undisclosed}
source_url: {URL or null}
format:
slots_available: [{slot list}]
typical_format: {brief description}
cost:
cost_status: {published | request_required | estimated | unknown}
range: {string or null, e.g., "$5,500 primary / $1,000 classified"}
source_url: {URL or null}
estimate_basis: {string or null}
estimate_evidence_url: {URL or null}
brand_alignment:
score: {high | medium | low}
notes: {short prose}
activity:
cadence: {weekly | bi-weekly | monthly | irregular}
last_publish: {YYYY-MM-DD or null}
status: {active | declining | dormant}
sponsor_adjacency: [{company list}]
contact_url: {URL or null}
dedup_status: {new | already_engaged | rejected_previously}
notes: {optional, anything noteworthy}
State pointer
After writing the doc, write a small pointer file to the skill's state/
directory so tier-expansion follow-ups can find the most recent doc:
Path: <skill-root>/state/last-doc.md
Content:
{absolute path to the doc just written}
Overwrite on each run. (The state/ directory is gitignored.)
Step 7: Tier expansion follow-up
If the user follows up with "expand tier 2," "expand tier 3," "show tier 2," or
similar, do NOT re-run discovery. Instead:
- Read
<skill-root>/state/last-doc.md to get the path to the most recent doc.
- If the file does not exist, inform the user: "I don't have a recent run on
record. Re-run the skill with your brief or category."
- Read the doc. Locate the requested tier section.
- Render every per-result block in that tier inline in chat. Format each as
compact human-readable prose (not YAML — convert the structured fields into
readable lines):
{tier section header}
{N}. {name} ({type})
- URL: {url}
- Audience: {description} (fit: {fit_score})
- Reach: {value} {unit} ({verification}, source: {source_url})
- Cost: {cost_status} — {range or contact URL or estimate basis}
- Brand alignment: {score} — {notes}
- Activity: {cadence}, last publish {last_publish}, {status}
- Sponsor adjacency: {list}
- {dedup_status if not "new"}
- End with: "Want to expand a different tier, or run a new brief?"
No web_search or web_fetch calls during tier expansion. Pure read-from-disk.
Step 8: Privacy review of output
Before completing the run, confirm:
- The output doc was written to the resolved output path (not the repo root, not
any tracked location).
- The state pointer file was written to
<skill-root>/state/last-doc.md.
- No proprietary content was rendered into the chat summary in a way that could
end up in a transcript that leaks (the chat summary is fine — Tier 1 picks and
next steps are appropriately bounded — but double-check no engagement history,
no internal performance data, no customer names from the no-fly list slipped
through).
If anything is off, surface it to the user.
Known limitations
- Rate-card public availability is partial (~40–60% hit rate). Many pubs
require contact for rates. Reflected in
cost_status: request_required.
- Tier calibration is fuzzy on first runs. Expect to refine the rubric in
Step 3 after the first few invocations.
- Cooperpress bias is a real risk. Cooperpress dominates dev newsletters and
has the best rate-card visibility. Adjacency signals + non-Cooperpress
directories should counter, but watch for it in early outputs.
- Search budget cap may miss long-tail pubs. Documented in the methodology
footer.
- No external API access in v1. Metadata accuracy depends on what
publications publicly disclose.
Roadmap (not implemented in v1)
- v2: earned/guest opportunities (
engagement_type: earned)
- v2: KOL sourcing (likely a separate
kol-opportunity-finder skill)
- v2: video sponsorships
- v2: Tiger Den lookup for engagement-history dedup
- v2: Tiger Den write of
media_opportunity records
- v3: Hybrid discovery via ListenNotes / Podchaser APIs