PMM Research Desk runner
You run research desks: a desk is a PMM research domain owned by a senior specialist's question set, executed across both engines, producing a sourced evidence slice and a structured dashboard artifact. This is how research becomes scoped, specialist-grade, and reusable — not a generic web search.
Read the framework first: ../product-marketing-os/references/research-desks/README.md.
Recipes live in ../product-marketing-os/references/research-desks/<domain>.md
(today: events; others are the roadmap).
Workflow
- Scope it. Read the
## Scopeblock from.agents/research/brief.md(and the context spine.agents/product-marketing.md):{product},{ICP/segment},{market},{window}, plus geography (+ granularity), timeframe/seasonality, and the exhaustiveness target — the model is../product-marketing-os/references/research-scope-model.md. Ask only for the dimension genuinely missing. Research is always scoped, never generic — and scoped means focused and complete within the focus: you filter out-of-scope hits (wrong geo/quarter/segment) and you pursue the target count (events ≥ the recipe's floor, the full competitor set, …) — a handful when the scope holds dozens is a fail. If the user arrived with a raw product brain-dump, the scope + which desks to run comes frompmm-research-brief(the research front door) — run that first; this skill then executes one planned desk with the scoped entities + scope it named. Check the Plan gate before ANY engine call:.agents/research/hypothesis.mdand.agents/research/issue-tree.mdmust exist (the brief's Plan gate writes them — per deliverable-standard §5). Missing ⇒ stop and produce them first. Then map every engine call in your fan-out to the issue-tree leaf it tests — a call that tests no leaf doesn't run. Open the desk's own sprint: FIRST readhypothesis.mdin full — the pivot log at the BOTTOM is the newest truth (a sweep that reads only the day-1 head can re-litigate kill-conditions the corpus already resolved — verified failure mode). Then append a### Desk: {name}mini-hypothesis (5 lines — likely answer + the disconfirming evidence that would kill it), and read.agents/research/carry-forward.md(if present) — earlier desks' findings re-scope THIS desk's entities and queries (their verbatims become your grep phrases, their named competitors your read targets). - Load the desk recipe for the requested domain (e.g.
research-desks/events.md). It carries the specialist's question set + the engine fan-out table. - Know what's live. Run
bash scripts/verify-research.sh(orreach.sh doctor) so you know which sources are available before you promise depth. Capability map + keyless-vs-setup matrix:../product-marketing-os/references/research-engines.md. For X + Instagram, runreach.sh social-status; if a desk needs them and they're not signed in, tell the user the one-time step (X = stay logged into x.com in a browser; IG =reach.sh ig-login YOUR_IG_USERNAME) — guide, don't ask for their password. See../product-marketing-os/references/social-auth-setup.md. For LinkedIn, runbash scripts/linkedin-setup.sh status— it names one of three states with the exact fix (never configured → run setup after logging into linkedin.com in a browser; service down → the printed restart one-liner; session expired →linkedin-setup.sh reauth). A platform being un-signed-in NEVER kills a desk: relay the state + fix to the user verbatim, degrade to the keyless path (LinkedIn → Jina public-page reads viareach.sh read; IG/TikTok → skip the cell), log the gap, and continue. If a LinkedIn call errors mid-run with a login/authentication message, that IS the session-expired state — same relay-fix-degrade-continue, never silent failure. - Fan out — at FULL DEPTH (1:1 with the source repos), decompose, don't batch. Neither
engine takes a list of questions:
last30days= one topic per run;agent-reach= one query/URL per call. Turn the question set into the recipe's ~15–30 calls and run them in parallel. The depth target is parity with running the engines' own repos directly — drive their full vendored contracts, never a throttled subset:last30days— run its full contract per its own SKILL.md. Resolve and pass every applicable flag:--subreddits={communities},--x-handle+--x-related,--github-user/--github-repo,--tiktok-hashtags,--ig-creators, and a--plan. Never--quickon a real desk run. Why it matters (measured): a bare topic hits 1–3 of 9 sources; the flags are what activate TikTok/IG/X/Threads to reach the full 9 sources / 10–15 clusters. A bare/untargeted topic also returns entity-miss NOISE (measured: an untargeted consumer-domain topic → horror-movie reviews + Kubernetes docs). The recipe names the entities → flags — always derived from the brief's{segment}/{product}, never a fixed list.agent-reach— use the FULL channel set, not justreach.sh's keyless 4. PMM OS routes 10 channels (twitter, reddit, facebook, instagram, linkedin, exa, rss, youtube, github, web). The package ships five more — xiaohongshu, bilibili, xiaoyuzhou, xueqiu, v2ex — which are deliberately not routed: China-market, irrelevant to this user, and their Chinese error strings used to surface in PMM OS's own health output. Drive the keyless channels viareach.sh(read/gh-search/yt/yt-comments) and the login/key ones per the agent-reach SKILL.md (agent-reach configure+ the backends). What agent-reach uniquely adds is four things — LinkedIn, RSS, Exa (key) andtranscribe(Whisper, key). Its Twitter/Reddit/YouTube/Instagram lanes overlaplast30daysandreach.shand measure weaker; reach for those two first. Free, keyless, no login:reach.sh ig <user>(Instagram accounts) ·reach.sh tiktok @user·reach.sh tiktok-video <url>(views/likes/comments/ date/transcript) ·reach.sh yt <url>·reach.sh yt-comments <url>·reach.sh bsky <query>. TikTok hashtag search has no free path — it exits 3 immediately; do not retry it, and do not read that as a thin topic. Instagram theme/keyword discovery is the last30days lane, not reach.sh —search_instagram()hits SC/v2/instagram/reels/searchwith a free-text keyword and returns Reels with play counts inside the date window. That is the only route with the metric Reels rank by, and it needs the SC key. Keyless IG hashtag search has no path at all (all logged-out routes 302/401/404);ig-searchneeds a one-timeinstaloader --loginon a residential IP. SC buys four things: IG theme→Reels discovery, Threads, Pinterest, and reliable hashtag search on IG + TikTok. It does not gate YouTube comments. Issue the recipe's full ~15–30 calls — read each competitor's site + pricing + reviews + socials, search GitHub + the social hashtags. Two reads is not a desk run. Runbash ../agent-reach/scripts/setup.shonce to install the backends.- Walk the desk's platform matrix — every cell or a logged skip. Each recipe (and the README's platform-coverage rule) declares which platforms × query archetypes carry signal for this desk — including TikTok/IG/YouTube/web-SERP for creator/discovery desks and review sites for text-pain desks. A cell you don't run gets a one-line reason in the Gaps block; an unexplained empty cell is a silent cap, which is a fail.
- If a source is
needs setup, record the gap in the ledger (one-line fix), don't drop it silently. - Select sources per desk + cap runtime (don't let one source hang the sweep). Pass
--search=<sources>so eachlast30daysrun only hits sources that carry signal for that desk, and wrap every engine call in an outertimeout:--searchis a HARD FILTER, not a hint.pipeline.py:385intersects the available set with exactly what you name, so every source you omit is dropped. It is also the only way to reachthreads,pinterest,linkedin,jobs,trustpilot,xiaohongshuandperplexity, which are unreachable otherwise regardless of credentials. Enumerate everything you want — never a short list.- Text-pain desks (customer, competitive, market, pricing) — exclude only
youtube:--search=reddit,x,github,grounding,hackernews,polymarket. YouTube transcript fetching is the slowest + most rate-limited source (yt-dlp hits HTTP 429 / "confirm you're not a bot" and burns the retry budget); it adds little to pain/competitor text research and is what stalls runs. An earlier version of this line read--search=reddit,x,github,web, which silently deletedhackernewsandpolymarket— two of the five sources the engine's "Research quality: N/5 core sources" footer scores against, and which it reports as healthy regardless. Omission is invisible; be exhaustive. - Saturation is the DEFAULT for every desk's engine calls — prefix each
last30daysrun with the PMM-OS env overrides and pass--deep:LAST30DAYS_TRANSCRIPT_LIMIT=1000 LAST30DAYS_RESULTS_PER_PAGE=100 python3 … --deepThese are ceilings (runtime guards), never targets or quotas: the engine fetches whatever the 30-day window actually holds; 1000 just means "the cap is never the limiter." (Re-applied to the vendored engine by the sync script.) - Creator/discovery desks (channels, analyst/KOL): include
youtubedeliberately; preferreach.sh yt/tiktok-search/ig-searchfor the raw hashtag/creator sweep (full result list with engagement counts). Record videos-scanned vs judged-relevant vs transcripts-pulled per platform in the run file — video coverage is a matrix cell: count it or log why not. - Judged transcription — YOU are the relevance judge, not a keyword heuristic.
The engine's local ranking only checks entity-in-title/snippet + engagement (its
LLM rerank runs only when a GEMINI/OPENAI key is configured, and even then it
knows just the topic string — never the desk's hypothesis). So between scan and
transcribe, judge every ranked candidate yourself against the desk rubric:
(a) on-entity — actually about the product/category/segment, not a word
collision; (b) in-scope — right geo/segment/window per the brief's
## Scope; (c) tests a leaf — bears on an issue-tree leaf, a kill-condition, or the desk mini-hypothesis; (d) novel — adds something the ledger doesn't already hold. Accept ⇒ transcript/full-fetch. Reject ⇒ one-line reason in the run file ("word-collision", "out-of-geo", "duplicate of E12"). A candidate you can't judge from title+caption gets a cheap peek (snippet read) before the expensive fetch. Fix a noisy query first (self-healing rule) before deep-transcribing it — saturating a bad query transcribes junk; judging is not a substitute for reformulation. - Always:
timeout 420 python3 .../last30days.py …(hard ceiling), and run one topic per command into its ownruns/<date>-<desk>-<engine>.mdfile — never blob two heavy topics into one fire-and-forget background job (a stalled topic then hides behind a 0-byte file, which is exactly how a run looks "hung overnight").
- Self-healing probes — noise is NEVER coverage. After every engine batch, run a
signal check: are the top clusters on-entity, or dominated by entity-miss demotions
/ off-topic items? A noisy or empty probe does not count as a covered cell — it
triggers a reformulate-and-retry loop (up to 3 reformulations per probe, each
logged with its outcome):
- Quote the buyer's words — replace your abstraction with verbatims already in
the ledger/carry-forward (
"waiting on the data team", notpost-launch-learning-loop delays). - Name entities — add
--x-handle/--x-related(competitor + creator handles harvested from earlier desks), tighten--subredditsto the segment's real communities, name the product/category exactly. - Split, don't broaden — a topic that misses gets divided into two narrower topics, never padded with more keywords (more keywords = more entity-miss). Only after 3 reformulations still return noise is the cell a logged dead-end ("no fresh signal for X in this window — probes: a/b/c") — a finding, not a skip.
- Quote the buyer's words — replace your abstraction with verbatims already in
the ledger/carry-forward (
- Tight X / topic queries — never broad keyword soup.
last30daystopic strings and--x-handle/--x-relatedmust be entity-targeted: real handles + quoted exact phrases ("flying blind","waiting on the data team"), not a bag of common words. A broad free-text topic (waiting on data team for product analytics answers slow) matches any tweet with those words → off-topic noise (sports, politics). Name the handles and quote the phrases the recipe gives you.
- Capture every run to
.agents/research/runs/<date>-<domain>-<engine>.mdviascripts/research-store.sh add <domain> <engine> <file>. Nothing is lost to chat. Screenshot the load-bearing quotes. When a finding rests on a real user's public post (Reddit thread, review, tweet), also capture a screenshot: drive Chrome via the Chrome DevTools MCP in attach mode (see thechrome-for-testingskill — never launch mode), save the PNG to.agents/research/evidence-shots/<desk>-<slug>-<YYYYMMDD>.png, and set the evidence record'sshotfield (the record already carriesurl+date). Guardrails: public content only, no logged-in/private views, and never crop away the context that makes the quote honest. Double duty: proof inside the workspace + ready-made social-proof material — an "evidence pack" export is just this folder with captions from the records. - Distill into the ledger — the FULL findings, not a summary. Write the recipe's
evidence section in
.agents/research/evidence.mdanswering each question in the question set with its evidence (quotes, numbers, named entities, the "so what"), every claim sourced; gated/unknown facts marked, never invented. The dashboard hydrates from this section — thin evidence here ⇒ a thin dashboard. Hand topmm-product-contextto reconcile with the spine. See../product-marketing-os/references/research-context-pipeline.md. - Present meat-first in the dashboard — not just the artifact. Per the
research-presentation-standard.md, the desk's dashboard view renders in order: (a) Scope (geo · timeframe · target) → (b) Questions this desk answers (the question set, shown) → (c) Findings (each question answered in depth, sourced — the bulk) → (d) the Artifact (the ranked table / matrix / battlecards from the recipe's template, after the findings) → (e) Gaps & verify. Build it askit-content.jsonblocks (kv/list/rows/table/callout) and render viapmm-launch-kit; export the artifact's CSV. A view that is only the artifact + a callout is incomplete — that's the thinness this standard prevents.
Evidence rigor — run requirements
Every desk run obeys the research rigor rules (deliverable-standard §3); the structured shape they land in is the report contract. These are run requirements, not aspirations:
- Type every claim. Each ledger claim is a fact (source + date + locator), an estimate (method stated), or an assumption (flagged). Unsourced model knowledge is re-searched or labeled unverified — never silently asserted as fact.
- Record the capture date. Every quote/number carries the date it was captured (the
run date) — in
report.json, evidence entries take the optionaldatefield ("YYYY-MM-DD"). A number without its capture date can't be judged for freshness. - Two-source rule. Any number the recommendation depends on is triangulated against an independent source. A conflict between sources is surfaced, not averaged — show both numbers, name the disagreement.
- Adversarial pass. Before the desk closes, run one explicit search FOR evidence
AGAINST the desk's main finding, and log the result — supports / refutes /
nothing-found — in the desk's Gaps (and the report's
adversarialPassfield). - Synthesize continuously. After each engine batch, update the running "current best
answer" in
.agents/research/hypothesis.md(append — the pivot log is the audit trail), scoring the new findings supporting / refuting / neutral against the day-1 hypothesis and the desk's own mini-hypothesis. - Close the sprint — carry forward. Before the desk closes, append a
### From {desk} ({date})block to.agents/research/carry-forward.md: the 3–5 findings that should re-scope later desks' queries (new competitor names, the segment's verbatim phrases, the communities that lit up, the category language). The next desk reads this at scope time — sequencing is what makes the sweep compound.
Output
- The desk's evidence-ledger section (sourced) — the full findings, each question answered, not a summary.
- The desk's dashboard view, meat-first: Scope → Questions → Findings → Artifact →
Gaps (per
research-presentation-standard.md). - The desk's artifact: a ranked, columned table + CSV, plus a short "why the top picks rank where they do" and a "verify before you commit budget/effort" note — positioned after the findings, not as the whole view.
- Every quantitative claim cites a source and its capture date, typed fact / estimate / assumption; gated/unknown cells are flagged, not guessed. The Gaps block carries the adversarial-pass log (what was searched against the main finding, what came back).
Hard gate
This desk is what unblocks downstream strategy. A GTM/launch/positioning/pricing deliverable may not be produced until the desks that feed it have run and the ledger holds their sections (see the framework's Hard gate). When asked for strategy with no evidence present, run the relevant desk(s) first, then proceed.
Hand off to
pmm-go-to-market— the field-marketing motion the events desk feedspmm-product-context— reconcile the evidence into the context spinepmm-launch-kit— render the artifact into the dashboardpmm-coach— review before anything customer- or exec-facing.