Event Prospecting Take a conference URL → get a ranked list of people the AE should talk to, with a "why reach out" rationale per person. Required : env var and the CLI installed ( ). Use for API calls and / for JS-heavy speaker pages. Path rules : Always use the full literal path in all Bash commands — NOT or (both trigger "shell expansion syntax" approval prompts). Resolve the home directory once and use it everywhere. When constructing subagent prompts, replace with the full literal path (typically ). Output directory : All event prospecting output goes to . Final deliverable is (people gr…

)\nCOUNT=$(echo \"$PROFILES\" | grep -c .)\n\nif [ -z \"$USER_SLUG\" ]; then\n if [ \"$COUNT\" -eq 0 ]; then\n echo \"No profiles found in {SKILL_DIR}/profiles/. Copy profiles/example.json to profiles/\u003cyour_slug>.json and fill it in, or run the company-research skill to build one.\"\n exit 1\n elif [ \"$COUNT\" -eq 1 ]; then\n USER_SLUG=$PROFILES\n echo \"Using the only profile available: ${USER_SLUG}\"\n else\n echo \"Multiple profiles found:\"\n echo \"$PROFILES\" | sed 's/^/ - /'\n echo \"Re-invoke with --user-company \u003cslug> to pick one.\"\n exit 1\n fi\nfi\n\ntest -f {SKILL_DIR}/profiles/${USER_SLUG}.json || {\n echo \"Profile not found: profiles/${USER_SLUG}.json\"\n exit 1\n}\ncat {SKILL_DIR}/profiles/${USER_SLUG}.json\n```\n\nThe profile yields: `company`, `product`, `icp_description`, `existing_customers`. These get embedded verbatim in every subagent prompt downstream.\n\n## Step 2: Recon\n\nDetect the event platform and extraction strategy. One command:\n\n```bash\nnode {SKILL_DIR}/scripts/recon.mjs {EVENT_URL} {OUTPUT_DIR}\n```\n\nWrites `{OUTPUT_DIR}/recon.json` with `platform`, `strategy`, and (for Next.js) `nextDataPaths`. See `references/event-platforms.md` for the platform catalog and detection priority.\n\nExpected outcomes:\n- Stripe Sessions class (Next.js): `platform: \"next-data\"`, 1-3 paths\n- Sessionize: `platform: \"sessionize\"`\n- Lu.ma / Eventbrite: `platform: \"luma\" | \"eventbrite\"`\n- Anything else: `platform: \"custom\"`, `strategy: \"markdown\"` (best-effort fallback)\n\n## Step 3: Extract People\n\n```bash\nnode {SKILL_DIR}/scripts/extract_event.mjs {OUTPUT_DIR} --user-company {USER_SLUG}\n```\n\nReads `recon.json`, dispatches to the platform-specific extractor, writes `people.jsonl` (one speaker per line) and `seed_companies.txt` (deduped companies).\n\nThe `--user-company` flag also drops the host-org's own employees (a Stripe-hosted event drops Stripe employees) and the user's own employees from the speaker list — those aren't prospects.\n\nSanity-check the output:\n```bash\nwc -l {OUTPUT_DIR}/people.jsonl {OUTPUT_DIR}/seed_companies.txt\nhead -3 {OUTPUT_DIR}/people.jsonl\n```\n\nIf `people.jsonl` is empty or under ~10 lines, recon picked the wrong platform — see `references/event-platforms.md` and re-run with adjusted strategy.\n\n## Step 4: Group by Company\n\n`extract_event.mjs` emits `seed_companies.txt` already (one company per line, deduped, sorted). This step is informational — verify the count looks reasonable before fanning out:\n\n```bash\nwc -l {OUTPUT_DIR}/seed_companies.txt\n```\n\nExpected: roughly 0.4-0.6× the speaker count (most events have ~2 speakers per company on average, some companies send 5+, many send 1).\n\n## Step 5: ICP Triage\n\n**Fast pass — one tool call per company, no deep research.** Score every company in `seed_companies.txt` against the user's ICP and write a thin triage stub to `companies/{slug}.md`. Companies with `icp_fit_score >= --icp-threshold` (default 6) advance to Step 7's deep research; the rest stay as triage stubs.\n\n**Dispatch pattern**: split `seed_companies.txt` into batches of ~10 and fan out N subagents in a SINGLE Agent batch (multiple Agent tool calls in one message). Each subagent runs the prompt from `references/workflow.md` → \"ICP Triage\" section. Hard cap: **1 tool call per company** (just `extract_page.mjs` on the homepage), enforced via the `# browse call N/1` comment pattern.\n\n```bash\n# Build batch files: each batch line is \"name|guessed_homepage|slug\".\n# extract_event.mjs only emits company NAMES (no URLs), so we slugify and guess\n# https://{slug-without-spaces}.com as the canonical homepage. The triage subagent\n# is allowed to write product_description: \"Unknown — homepage content not accessible\"\n# and cap score at 3 if the guessed URL 404s — that's the documented fallback in\n# workflow.md (rule 3 of the ICP Triage prompt). Burning a real browse cloud search to\n# discover the URL would bust the 1-call-per-company HARD CAP.\nnode -e '\nconst fs = require(\"fs\");\nconst slugify = (s) => (s || \"\").toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\");\nconst seed = fs.readFileSync(\"{OUTPUT_DIR}/seed_companies.txt\", \"utf-8\").split(\"\\n\").filter(Boolean);\nconst lines = seed.map(c => {\n const slug = slugify(c);\n const guessedHost = c.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n return `${c}|https://${guessedHost}.com|${slug}`;\n});\nfs.writeFileSync(\"{OUTPUT_DIR}/_seed_with_urls.txt\", lines.join(\"\\n\") + \"\\n\");\n'\n\n# Split into ~10-company batches\nsplit -l 10 {OUTPUT_DIR}/_seed_with_urls.txt {OUTPUT_DIR}/_batch_triage_\n\n# Count batches → number of subagents to dispatch (cap at 6 per message; second wave for the rest)\nls {OUTPUT_DIR}/_batch_triage_* | wc -l\n```\n\nThen in a single message, dispatch one Agent call per batch (up to 6 in parallel; subsequent waves after the first returns). Each Agent gets the prompt from `references/workflow.md` → \"ICP Triage\" with these substitutions before sending:\n- `{SKILL_DIR}` → full literal skill path (e.g. `/Users/jay/skills/skills/event-prospecting`)\n- `{OUTPUT_DIR}` → full literal output path\n- `{USER_COMPANY}`, `{USER_PRODUCT}`, `{ICP_DESCRIPTION}` → from the loaded profile\n- `{EVENT_NAME}` → `recon.json` `.title`\n- `{COMPANY_LIST}` → contents of the batch file (e.g. `cat {OUTPUT_DIR}/_batch_triage_aa`)\n- `{TOTAL}` → number of lines in this batch (substitute into `# browse call N/{TOTAL}`)\n\n**Agent dispatch (skeleton, repeat per batch in one message)**:\n\n```\nAgent(\n description: \"ICP triage batch aa\",\n prompt: \u003cICP Triage prompt from workflow.md with all placeholders substituted>,\n subagent_type: \"general-purpose\"\n)\nAgent(\n description: \"ICP triage batch ab\",\n prompt: \u003csame prompt template, COMPANY_LIST swapped to batch ab>,\n subagent_type: \"general-purpose\"\n)\n... up to 6 per message\n```\n\nAfter all subagents return, verify every company in `seed_companies.txt` has a corresponding `companies/{slug}.md`:\n\n```bash\nls {OUTPUT_DIR}/companies/*.md | wc -l\n# Should equal `wc -l {OUTPUT_DIR}/seed_companies.txt`\n```\n\nClean up the batch files: `rm {OUTPUT_DIR}/_batch_triage_*`.\n\n## Step 6: Filter by ICP Threshold\n\nRead each `companies/*.md` frontmatter, keep those with `icp_fit_score >= 6` (or whatever `--icp-threshold` is). Write the surviving company slugs to `{OUTPUT_DIR}/icp_fits.txt`:\n\n```bash\nTHRESHOLD=6 # from --icp-threshold flag\nfor f in {OUTPUT_DIR}/companies/*.md; do\n score=$(awk '/^icp_fit_score:/{print $2; exit}' \"$f\")\n if [ -n \"$score\" ] && [ \"$score\" -ge \"$THRESHOLD\" ]; then\n basename \"$f\" .md\n fi\ndone > {OUTPUT_DIR}/icp_fits.txt\n\nwc -l {OUTPUT_DIR}/icp_fits.txt\n```\n\nExpected: 20-40% of `seed_companies.txt`. If the survival rate is \u003c 10%, the threshold may be too high or the ICP description too narrow — surface a warning to the user.\n\n## Step 7: Deep Research\n\nFull Plan→Research→Synthesize on ICP-fit companies only. Hard cap: **5 tool calls per company** (homepage extract + 2-3 sub-question searches + 1-2 supplementary fetches). Subagents OVERWRITE the existing `companies/{slug}.md` triage stub with the richer deep-research version (frontmatter `triage_only: false`).\n\n**Dispatch pattern**: split `icp_fits.txt` into batches of ~5 (deep mode default) and fan out one Agent per batch in a SINGLE message (up to 6 Agents per message). Each Agent gets the prompt from `references/workflow.md` → \"Deep Research\" with these substitutions:\n- `{SKILL_DIR}`, `{OUTPUT_DIR}`, `{USER_COMPANY}`, `{USER_PRODUCT}`, `{ICP_DESCRIPTION}`\n- `{EVENT_NAME}` (from `recon.json` `.title`), `{EVENT_CONTEXT}` (track / topic, manually inferred from the event homepage)\n- `{COMPANY_LIST}` → contents of the batch file (each line `slug|website`)\n\n```bash\n# Build {company-slug|website} pairs by reading frontmatter from each triage stub\nwhile read slug; do\n website=$(awk '/^website:/{print $2; exit}' {OUTPUT_DIR}/companies/${slug}.md)\n echo \"${slug}|${website}\"\ndone \u003c {OUTPUT_DIR}/icp_fits.txt > {OUTPUT_DIR}/_deep_targets.txt\n\n# Split into ~5-company batches (deep mode)\nsplit -l 5 {OUTPUT_DIR}/_deep_targets.txt {OUTPUT_DIR}/_batch_deep_\nls {OUTPUT_DIR}/_batch_deep_* | wc -l\n```\n\n**Agent dispatch (skeleton, repeat per batch in one message)**:\n\n```\nAgent(\n description: \"Deep research batch aa\",\n prompt: \u003cDeep Research prompt from workflow.md with all placeholders substituted; COMPANY_LIST = cat _batch_deep_aa>,\n subagent_type: \"general-purpose\"\n)\nAgent(\n description: \"Deep research batch ab\",\n prompt: \u003csame template, COMPANY_LIST = cat _batch_deep_ab>,\n subagent_type: \"general-purpose\"\n)\n... up to 6 per message; second wave after the first returns\n```\n\nAfter all subagents return, verify the deep-research files exist and have `triage_only: false`:\n\n```bash\ngrep -l \"triage_only: false\" {OUTPUT_DIR}/companies/*.md | wc -l\n# Should equal wc -l icp_fits.txt\n```\n\n## Step 8: Enrich Speakers\n\nPer person: harvest LinkedIn URL, recent activity (podcast / blog / talk / GitHub / X), and write `people/{slug}.md`. Hard cap: **4 tool calls per person**, three lanes:\n\n1. `browse cloud search \"{name} {company} linkedin\"` (always)\n2. `browse cloud search \"{name} podcast OR talk OR blog 2026\"` (deep+)\n3. `browse cloud search \"{name} github\"` (deeper)\n4. `browse cloud search \"{name} site:x.com OR site:twitter.com\"` (deeper, best-effort)\n\nQuick mode: skip Step 8 entirely. Deep mode: lanes 1-2. Deeper mode: lanes 1-4.\n\n### Step 8a — Ask the user: scope of enrichment\n\nBefore dispatching, compute the two candidate counts and ask the user to choose. The default is **ICP-fit only** (faster, cheaper, what most users want); enriching every speaker is opt-in because cost scales linearly with people enriched.\n\n```bash\nTOTAL=$(wc -l \u003c {OUTPUT_DIR}/people.jsonl)\nICP_FITS=$(node -e '\nconst fs = require(\"fs\");\nconst fits = new Set(fs.readFileSync(\"{OUTPUT_DIR}/icp_fits.txt\", \"utf-8\").split(\"\\n\").filter(Boolean));\nconst slug2name = {};\nfor (const slug of fits) {\n const md = fs.readFileSync(`{OUTPUT_DIR}/companies/${slug}.md`, \"utf-8\");\n const m = md.match(/^company_name:\\s*(.+)$/m);\n if (m) slug2name[slug] = m[1].trim();\n}\nconst want = new Set(Object.values(slug2name).map(s => s.toLowerCase()));\nconst ppl = fs.readFileSync(\"{OUTPUT_DIR}/people.jsonl\",\"utf-8\").split(\"\\n\").filter(Boolean).map(JSON.parse);\nconsole.log(ppl.filter(p => p.company && want.has(p.company.toLowerCase())).length);\n')\n\n# Lanes per person: 2 (deep) or 4 (deeper) — match {DEPTH}\nLANES=2 # or 4 for deeper\necho \"ICP fits: ${ICP_FITS} speakers × ${LANES} = $((ICP_FITS * LANES)) calls\"\necho \"All: ${TOTAL} speakers × ${LANES} = $((TOTAL * LANES)) calls\"\n```\n\nThen ask via `AskUserQuestion` — clean two-option choice with the quantified cost on each:\n\n```\nAskUserQuestion(questions: [\n {\n question: \"Enrich which speakers?\",\n header: \"Enrichment scope\",\n multiSelect: false,\n options: [\n { label: \"ICP fits only\", description: \"${ICP_FITS} speakers, ~$((ICP_FITS * LANES)) calls (recommended)\" },\n { label: \"All speakers\", description: \"${TOTAL} speakers, ~$((TOTAL * LANES)) calls\" }\n ]\n }\n])\n```\n\nSave the chosen scope as `ENRICH_SCOPE=icp_fits` or `ENRICH_SCOPE=all`. If the user picks \"All speakers\" and `TOTAL × LANES > 600`, print a warning and ask once more — that's a 10+ minute run with hundreds of tool calls.\n\n### Step 8b — Filter and batch\n\n```bash\n# Build _people_to_enrich.jsonl based on ENRICH_SCOPE\nif [ \"$ENRICH_SCOPE\" = \"all\" ]; then\n cp {OUTPUT_DIR}/people.jsonl {OUTPUT_DIR}/_people_to_enrich.jsonl\nelse\n node -e '\nconst fs = require(\"fs\");\nconst fits = new Set(fs.readFileSync(\"{OUTPUT_DIR}/icp_fits.txt\", \"utf-8\").split(\"\\n\").filter(Boolean));\nconst slug2name = {};\nfor (const slug of fits) {\n const md = fs.readFileSync(`{OUTPUT_DIR}/companies/${slug}.md`, \"utf-8\");\n const m = md.match(/^company_name:\\s*(.+)$/m);\n if (m) slug2name[slug] = m[1].trim();\n}\nconst wantNames = new Set(Object.values(slug2name).map(s => s.toLowerCase()));\nconst lines = fs.readFileSync(\"{OUTPUT_DIR}/people.jsonl\", \"utf-8\").split(\"\\n\").filter(Boolean);\nconst keep = lines.filter(l => {\n const p = JSON.parse(l);\n return p.company && wantNames.has(p.company.toLowerCase());\n});\nfs.writeFileSync(\"{OUTPUT_DIR}/_people_to_enrich.jsonl\", keep.join(\"\\n\") + \"\\n\");\nconsole.error(`Enriching ${keep.length} of ${lines.length} speakers`);\n'\nfi\n\n# Split into ~5-person batches\nsplit -l 5 {OUTPUT_DIR}/_people_to_enrich.jsonl {OUTPUT_DIR}/_batch_people_\n```\n\nThen in a single message, dispatch one Agent call per batch (up to 6 per message) with the prompt from `references/workflow.md` → \"Person Enrichment\". Each subagent's prompt should include:\n- `{SKILL_DIR}`, `{OUTPUT_DIR}`, `{DEPTH}` (`deep` | `deeper`)\n- `{USER_COMPANY}`, `{USER_PRODUCT}`, `{ICP_DESCRIPTION}`\n- `{EVENT_NAME}` (from `recon.json` `.title`)\n- `{LANES}` → `2` for deep mode, `4` for deeper mode (substituted into `# browse call N/{LANES}`)\n- `{PEOPLE_BATCH}` → contents of `_batch_people_aa` (each line a JSON record from `people.jsonl`)\n\n**Agent dispatch (skeleton, repeat per batch in one message)**:\n\n```\nAgent(\n description: \"Person enrichment batch aa\",\n prompt: \u003cPerson Enrichment prompt from workflow.md with all placeholders substituted; PEOPLE_BATCH = cat _batch_people_aa>,\n subagent_type: \"general-purpose\"\n)\nAgent(\n description: \"Person enrichment batch ab\",\n prompt: \u003csame template, PEOPLE_BATCH = cat _batch_people_ab>,\n subagent_type: \"general-purpose\"\n)\n... up to 6 per message\n```\n\nAfter all subagents return, verify the people files exist:\n\n```bash\nls {OUTPUT_DIR}/people/*.md | wc -l\n# Should equal wc -l _people_to_enrich.jsonl\n```\n\n## Step 9: Compile Report\n\nGenerate the company-grouped HTML index, alternate views, and CSV in one command:\n\n```bash\nnode {SKILL_DIR}/scripts/compile_report.mjs {OUTPUT_DIR} --open\n```\n\nThis generates:\n- `{OUTPUT_DIR}/index.html` — people grouped by company, ranked by company ICP score (opens in browser)\n- `{OUTPUT_DIR}/people.html` — filterable speaker list (alternate view)\n- `{OUTPUT_DIR}/companies.html` — ICP-ranked company table with attendees\n- `{OUTPUT_DIR}/results.csv` — cold-outbound-ready spreadsheet\n\nThen present a summary in chat:\n\n```\n## Event Prospecting Complete — {Event Name}\n\n- **Total speakers extracted**: {count}\n- **Unique companies**: {count}\n- **ICP fits (score ≥ {threshold})**: {count}\n- **Speakers enriched**: {count}\n- **Score distribution** (companies):\n - Strong fit (8-10): {count}\n - Partial fit (5-7): {count}\n - Weak fit (1-4): {count}\n- **Report opened in browser**: {OUTPUT_DIR}/index.html\n```\n\nShow the **top 5 people cards** as a markdown table sorted by company ICP score, then offer to:\n- Adjust `--icp-threshold` and re-run Steps 6-9\n- Export the CSV to a CRM","attachment_filenames":["profiles/example.json","references/event-platforms.md","references/example-research.md","references/report-template.html","references/research-patterns.md","references/workflow.md","scripts/__fixtures__/stripe-snapshot.json","scripts/compile_report.mjs","scripts/extract_event.mjs","scripts/extract_page.mjs","scripts/package.json","scripts/recon.mjs"],"attachments":[{"filename":"profiles/example.json","content":"{\n \"company\": \"\",\n \"website\": \"\",\n \"product\": \"\",\n \"icp_description\": \"\",\n \"existing_customers\": [],\n \"competitors\": [],\n \"use_cases\": [],\n \"researched_at\": \"\"\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":170,"content_sha256":"a49b5dfef88ce8b31b350f6f2dfa906df3b37dd5befa183938faaf976e4dd808"},{"filename":"references/event-platforms.md","content":"# Event-Prospecting — Platform Reference\n\n## Contents\n- [Detection Priority](#detection-priority) — order recon.mjs probes\n- [Next.js / `__NEXT_DATA__`](#nextjs--__next_data__) — Stripe Sessions class\n- [Sessionize](#sessionize) — public JSON API\n- [Lu.ma](#luma) — JSON-LD Event block\n- [Eventbrite](#eventbrite) — JSON-LD Event block\n- [Custom / Markdown Fallback](#custom--markdown-fallback) — last resort\n- [Adding a New Platform](#adding-a-new-platform) — contributor guide\n\n---\n\n## Detection Priority\n\n`recon.mjs` probes the event URL once via `browse open` + `browse eval`, then chooses the first matching platform from this list:\n\n1. **Next.js / `__NEXT_DATA__`** — `document.getElementById('__NEXT_DATA__')` returns a `\u003cscript>` tag.\n2. **Sessionize** — `\u003cmeta name=\"generator\">` content matches `/sessionize/i`.\n3. **Lu.ma** — `location.hostname` matches `/lu\\.ma/`.\n4. **Eventbrite** — `\u003cmeta property=\"og:site_name\">` matches `/eventbrite/i`.\n5. **JSON-LD Event** — at least one `\u003cscript type=\"application/ld+json\">` block has `@type === 'Event'`.\n6. **Custom / markdown fallback** — none of the above; the extractor uses `browse get markdown` and parses speaker blocks heuristically.\n\nThe order matters: Next.js sites often *also* embed JSON-LD, so probing `__NEXT_DATA__` first gives us the structured data path before falling back to JSON-LD heuristics.\n\n---\n\n## Next.js / `__NEXT_DATA__`\n\n**Detection signature**\n\n```js\n!!document.getElementById('__NEXT_DATA__')\n```\n\nThis script tag is emitted by every `getServerSideProps` / `getStaticProps` Next.js page. Its `textContent` is a JSON blob containing every prop hydrated into the React tree at build/request time — including the speaker list for an event microsite.\n\n**Extraction strategy** — `next-data-eval`\n\n`recon.mjs` walks the parsed JSON looking for arrays whose elements are objects with both a `name`-ish key AND a `linkedin` substring somewhere. It records the JSON path of every such array (e.g. `.props.pageProps.featuredSpeakers.speakers.items`) into `recon.nextDataPaths`. The Phase B extractor then runs ONE `browse eval` to harvest those arrays and union them into a single people list.\n\n**Sample output shape** (Stripe Sessions 2026)\n\n```json\n{\n \"platform\": \"next-data\",\n \"strategy\": \"next-data-eval\",\n \"nextDataPaths\": [\n \".props.pageProps.featuredSpeakers.speakers.items\",\n \".props.pageProps.moreSpeakers.speakers.items\"\n ]\n}\n```\n\nA typical speaker object inside one of those arrays:\n\n```json\n{\n \"name\": \"Patrick Collison\",\n \"title\": \"CEO and Co-founder\",\n \"companyName\": \"Stripe\",\n \"linkedInProfile\": \"https://www.linkedin.com/in/patrickcollison/\",\n \"bio\": \"...\"\n}\n```\n\n**Known gotchas**\n\n- The walker also matches `talks[N].speakers` arrays (a denormalized re-listing of the same speakers per session). `recon.mjs` filters those out via regex so we don't double-count.\n- Some Next sites lazy-load speakers AFTER hydration. The 2.5s `browse wait timeout` is usually enough; if a site fails extraction, bump the wait to 5s before declaring it a different platform.\n- Field names vary across sites (`companyName` vs `company` vs `org`, `linkedInProfile` vs `linkedinUrl`). The Phase B extractor normalizes via fallback chains.\n\n---\n\n## Sessionize\n\n**Detection signature**\n\n```html\n\u003cmeta name=\"generator\" content=\"Sessionize.com\">\n```\n\n**Extraction strategy** — `sessionize-api` *(stub in v0.1; full implementation in a future phase)*\n\nSessionize exposes a public read-only JSON API at `https://sessionize.com/api/v2/{event_id}/view/Speakers`. The event ID is in the page URL or embedded JS. v0.1 of `recon.mjs` only sets `strategy: \"sessionize-api\"` and emits the URL — no API discovery yet.\n\n**Sample output shape**\n\n```json\n{\n \"platform\": \"sessionize\",\n \"strategy\": \"sessionize-api\"\n}\n```\n\n**Known gotchas**\n\n- Some Sessionize-hosted pages bury the event ID behind a custom domain. We may need to scan the page for `sessionize.com/api/v2/...` URLs and pull the ID from there.\n- The Sessionize API returns a flat speaker list — much cleaner than scraping Next.js, when it works.\n\n---\n\n## Lu.ma\n\n**Detection signature**\n\n```js\n/lu\\.ma/.test(location.hostname)\n```\n\nLu.ma always serves on `lu.ma` (or rarely a custom CNAME); the hostname check is decisive.\n\n**Extraction strategy** — `json-ld` *(stub in v0.1)*\n\nLu.ma embeds an `Event` JSON-LD block with attendee/speaker info, but the volume of structured data is event-dependent. v0.1 sets `strategy: \"json-ld\"` and defers actual extraction to Phase B+. The fallback markdown extractor handles Lu.ma pages reasonably well in the meantime.\n\n**Sample output shape**\n\n```json\n{\n \"platform\": \"luma\",\n \"strategy\": \"json-ld\"\n}\n```\n\n**Known gotchas**\n\n- Lu.ma often gates the full attendee list behind a login. Public-facing pages usually only show \"featured\" speakers, not the full list.\n- Some Lu.ma events are private; `recon.mjs` should NOT crash if `__NEXT_DATA__` isn't present and JSON-LD is empty — it falls through to the markdown strategy.\n\n---\n\n## Eventbrite\n\n**Detection signature**\n\n```html\n\u003cmeta property=\"og:site_name\" content=\"Eventbrite\">\n```\n\n**Extraction strategy** — `json-ld` *(stub in v0.1)*\n\nEventbrite emits standard `Event` JSON-LD on every public event page. Speakers are usually only in the prose body, not the structured data — Eventbrite is more of an RSVP platform than a speaker-directory platform. We may need to combine JSON-LD parsing with markdown extraction of the description body.\n\n**Sample output shape**\n\n```json\n{\n \"platform\": \"eventbrite\",\n \"strategy\": \"json-ld\"\n}\n```\n\n**Known gotchas**\n\n- Eventbrite event pages can be heavy. If `browse cloud fetch` fails or returns thin content, use `browse get markdown` instead.\n- Most Eventbrite events do NOT publish a speaker list at all. This platform is low-yield for prospecting.\n\n---\n\n## Custom / Markdown Fallback\n\n**Detection signature**\n\nNone of the above match. `recon.mjs` sets `platform: \"custom\"` and `strategy: \"markdown\"`.\n\n**Extraction strategy** — `markdown`\n\nThe Phase B extractor calls `browse get markdown`, splits the output on heading boundaries (`####`, `###`, `##`), and treats each block as a candidate speaker:\n- Line 1: name (must start with capital letter)\n- Line 2: title/role\n- Line 3: company\n- Anywhere in the block: `linkedin.com/in/{handle}`\n\nThis is a best-effort fallback. Coverage is typically 60-80% of the actual speaker list; field accuracy is lower than the structured paths.\n\n**Sample output shape**\n\n```json\n{\n \"platform\": \"custom\",\n \"strategy\": \"markdown\"\n}\n```\n\n**Known gotchas**\n\n- Many static event sites use cards (not headings) for speakers. The heading-split heuristic misses those entirely.\n- \"Section title\" lines (`## Speakers`, `## Schedule`) get parsed as candidate speakers and need to be filtered downstream by checking for the LinkedIn pattern.\n- If markdown extraction yields zero people, the pipeline should surface a \"platform unsupported\" error rather than emit an empty `people.jsonl`.\n\n---\n\n## Adding a New Platform\n\n1. **Add a detection branch** in `recon.mjs` `probe()` after the existing ones. Pick a cheap signal (a meta tag, hostname, or a specific script tag) that's distinctive.\n2. **Pick a strategy name** — kebab-case verb-phrase like `sessionize-api` or `json-ld-events`.\n3. **Add an extractor branch** in `extract_event.mjs` that handles the new strategy.\n4. **Add a section to this file** with: detection signature, extraction strategy, sample output shape, known gotchas.\n5. **Drop a fixture** under `scripts/__fixtures__/{platform}-snapshot.json` capturing the expected `recon.json` shape so future refactors don't silently break extraction.\n\nKeep the detection branches small. If a platform needs more than ~20 lines of detection logic, factor it out into `scripts/detectors/{platform}.mjs` and import.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7866,"content_sha256":"0cb9d282188767bfa4fd778ad53d4184afaed27aff4ee83c73b6e232dc517fe7"},{"filename":"references/example-research.md","content":"# Example Research Files\n\nEvent-prospecting writes TWO kinds of markdown files:\n\n1. **Company files** — one per company in `seed_companies.txt`, written to `{OUTPUT_DIR}/companies/{slug}.md`. Comes in two flavors: triage stubs (Step 5) and deep-research files (Step 7).\n2. **Person files** — one per speaker at an ICP-fit company, written to `{OUTPUT_DIR}/people/{slug}.md`. Created in Step 8.\n\nThe YAML frontmatter contains structured fields for report compilation. The body contains human-readable research.\n\n`{OUTPUT_DIR}` is the per-run Desktop directory set up by the main agent in Step 0 (e.g., `/Users/jay/Desktop/{event_slug}_prospects_2026-04-25-2030/`).\n\n---\n\n## Company File — Triage Stub (Step 5 output)\n\nEvery company in `seed_companies.txt` gets one of these. It captures a 1-call, ICP-only assessment.\n\n```markdown\n---\ncompany_name: OpenAI\nwebsite: https://openai.com\nproduct_description: AI lab building safe AGI; ChatGPT, GPT API, ChatGPT Agent\nicp_fit_score: 9\nicp_fit_reasoning: AI agents at scale need cloud browser infrastructure; ChatGPT Agent shipped Mar 2026\ntriage_only: true\nevent_context: Stripe Sessions 2026 — featured speaker on AI track\n---\n\n## Triage Notes\nHomepage: \"ChatGPT, GPT API, and ChatGPT Agent — AI tools and APIs for everyone.\"\nScore 9 because ChatGPT Agent ships browser-using AI agents at consumer scale — the canonical fit for browser infrastructure.\n```\n\n**Required fields**: `company_name`, `website`, `icp_fit_score`, `icp_fit_reasoning`, `triage_only: true`.\n\n---\n\n## Company File — Deep Research (Step 7 output)\n\nWhen a company's `icp_fit_score >= --icp-threshold`, Step 7's deep research overwrites the triage stub with this richer version. `triage_only` flips to `false`.\n\n```markdown\n---\ncompany_name: OpenAI\nwebsite: https://openai.com\nproduct_description: Foundational AI lab; products span ChatGPT (consumer chat), GPT API (developer access), and ChatGPT Agent (browser-using autonomous agent)\nindustry: AI / Foundation Models\ntarget_audience: Consumers, developers, enterprise — multi-segment\nkey_features: ChatGPT Agent | GPT-5 API | Sora video | enterprise data residency\nicp_fit_score: 9\nicp_fit_reasoning: ChatGPT Agent (Mar 2026) is a browser-using agent at consumer scale — directly addresses the \"agents need a browser\" wedge. Plus enterprise customers ship internal agents on top of GPT API.\nemployee_estimate: 3000+\nfunding_info: $11.3B raised; reported $300B valuation 2026\nheadquarters: San Francisco, CA\ntriage_only: false\nevent_context: Stripe Sessions 2026 — Greg Brockman featured speaker, Agents track\nevent_relevance: Three OpenAI speakers across the Agents and Infra tracks; ChatGPT Agent demo expected at the event\n---\n\n## Product\nFoundational AI lab. Three product surfaces: ChatGPT (consumer/team chat), GPT API (developer platform), ChatGPT Agent (autonomous browsing agent that completes multi-step tasks). Recently shipped Sora 2 for video.\n\n## Research Findings\n- **[high]** ChatGPT Agent launched Mar 2026 — autonomous web-browsing agent that books, shops, and researches on user's behalf (source: openai.com/index/chatgpt-agent)\n- **[high]** Stripe Sessions 2026 keynote includes Greg Brockman on the Agents track (source: stripesessions.com/speakers)\n- **[medium]** Hiring across \"Agent Reliability\" team — 12 open roles for browser-automation engineers (source: openai.com/careers, search 2026-04)\n- **[medium]** Reported partnership exploration with infrastructure providers for agent runtime (source: The Information, 2026-03)\n\n## Event Relevance\nThree speakers at Stripe Sessions across Agents and Infra tracks. ChatGPT Agent is the canonical use-case for browser-infrastructure-as-a-product. Pitch angle: durability + scale guarantees the in-house Browserbase-equivalent can't easily match.\n```\n\n**Additional fields vs the stub**: `industry`, `target_audience`, `key_features` (pipe-separated), `employee_estimate`, `funding_info`, `headquarters`, `event_relevance`.\n\n**Body sections**: `## Product`, `## Research Findings`, `## Event Relevance`.\n\n---\n\n## Person File (Step 8 output)\n\nOnly created for speakers at ICP-fit companies (those whose company file has `triage_only: false` after Step 7).\n\n```markdown\n---\nname: Greg Brockman\nslug: greg-brockman\ncompany: OpenAI\ncompany_slug: openai\ntitle: President & Co-founder\nimage: https://cdn.example.com/speakers/greg-brockman.jpg\nlinks:\n linkedin: https://www.linkedin.com/in/thegdb/\n x: https://x.com/gdb\n github: https://github.com/gdb\n blog: null\n podcast: https://lexfridman.com/greg-brockman/\nhook: Recent Lex Fridman conversation on agent reliability — direct fit for the browser-infrastructure durability story\ndm_opener: |\n Hey Greg — caught your Lex conversation on agent reliability and the\n \"agents are bottlenecked on the browser\" framing landed hard. We run\n the cloud-browser layer that ChatGPT Agent's competitors are shipping\n on. Worth a 15-min walkthrough before Sessions?\nrole_reason: Co-founder, sets infrastructure direction across product surfaces\nevent_name: Stripe Sessions 2026\nevent_context: Panelist, Agents track (\"From demo to dependable: making agents reliable\")\nicp_fit_score: 9\nicp_fit_reasoning: AI agents at scale need cloud browser infrastructure; ChatGPT Agent shipped Mar 2026\nenriched_at: 2026-04-25T20:30:00Z\n---\n\n## Why reach out\n- **Why the company**: ChatGPT Agent is the canonical browser-infra customer — see `companies/openai.md`\n- **Why the person**: Co-founder; sets infra direction; specifically called out agent reliability on Lex (Mar 2026)\n- **Hook**: Lex Fridman conversation on agent reliability (45 min, dropped 2026-03-12)\n\n## Public links\n- LinkedIn: https://www.linkedin.com/in/thegdb/\n- X: https://x.com/gdb\n- GitHub: https://github.com/gdb (OpenAI / personal)\n- Podcast: https://lexfridman.com/greg-brockman/\n\n## Recent activity\n- **[high]** Lex Fridman podcast episode on agent reliability, Mar 2026 (source: lexfridman.com/greg-brockman)\n- **[medium]** GitHub activity: contributed to openai/chatgpt-agent-evals (source: github.com/gdb)\n- **[medium]** X thread on \"the bottleneck for agents is the browser, not the model\" — Apr 2026 (source: x.com/gdb)\n```\n\n**Required fields**: `name`, `slug`, `company`, `links` (object), `hook`, `dm_opener`, `role_reason`, `event_name`, `event_context`, `icp_fit_score`.\n\n**Body sections**: `## Why reach out` (3 bullets that mirror the card), `## Public links`, `## Recent activity` (findings list with confidence levels).\n\n---\n\n## Field Rules\n\n### Company files\n\n- `key_features`: pipe-separated (`|`) list, NOT a JSON array\n- `icp_fit_score`: integer 1-10\n- `icp_fit_reasoning`: one line, references specific findings\n- `triage_only`: boolean (`true` for stubs, `false` after deep research)\n- `event_context`: how this company shows up at the event (sponsor tier, speaker count, track topics)\n- Filename: `{OUTPUT_DIR}/companies/{slug}.md` where slug is lowercase, hyphenated\n\n### Person files\n\n- `image`: speaker headshot URL extracted from the event site (preserved verbatim from the `people.jsonl` input record). May be `null` on platforms that don't expose it.\n- `links`: YAML object with keys `linkedin`, `x`, `github`, `blog`, `podcast`. Use `null` when not found, not empty string.\n- `hook`: one sentence, sourced from a specific finding (event-context, recent activity, or company-context). Never inferred from memory.\n- `dm_opener`: 2-3 sentences, multi-line YAML string with `|` pipe. References the hook, names a wedge tie-in, ends with a soft CTA.\n- `icp_fit_score` is INHERITED from the corresponding `companies/{company_slug}.md` — keeps cards rankable in the index.\n- Filename: `{OUTPUT_DIR}/people/{slug}.md` where slug is the lowercased + hyphenated person name (e.g. `greg-brockman.md`).\n\n### Both\n\n- One file per entity. If a subagent encounters a duplicate, OVERWRITE with richer data (e.g. Step 7 overwrites Step 5's triage stub for the same company).\n\n---\n\n## Writing via Bash Heredoc\n\nSubagents write these files using bash heredoc to avoid security prompts. Use the full literal `{OUTPUT_DIR}` path — no `~` or `$HOME`:\n\n```bash\ncat \u003c\u003c 'PERSON_MD' > /Users/jay/Desktop/stripesessions_prospects_2026-04-25-2030/people/greg-brockman.md\n---\nname: Greg Brockman\nslug: greg-brockman\n...\n---\n\n## Why reach out\n...\nPERSON_MD\n```\n\nUse `'PERSON_MD'` (quoted) as the delimiter to prevent shell variable expansion. Use `'COMPANY_MD'` for company files.\n\n**IMPORTANT**: Write ALL files in a SINGLE Bash call using chained heredocs to minimize permission prompts. One subagent batch (~5 people) = one Bash invocation = one permission prompt.\n\n```bash\ncat \u003c\u003c 'PERSON_MD' > {OUTPUT_DIR}/people/greg-brockman.md\n---\n...\n---\nPERSON_MD\ncat \u003c\u003c 'PERSON_MD' > {OUTPUT_DIR}/people/sam-altman.md\n---\n...\n---\nPERSON_MD\n```\n\nChained heredocs in one bash call. The subagent reports back ONLY a count, never raw content.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8905,"content_sha256":"f97a5fd78c7818ed33a075c2f4f556287f6aedf4bd37a5d737ab1dd61792a4cb"},{"filename":"references/report-template.html","content":"\u003c!DOCTYPE html>\n\u003chtml lang=\"en\">\n\u003chead>\n\u003cmeta charset=\"UTF-8\">\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\u003ctitle>Company Research — {{COMPANY_NAME}}\u003c/title>\n\u003clink rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n\u003clink href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n\u003cstyle>\n :root {\n --brand: #F03603;\n --high: #90C94D;\n --medium: #F4BA41;\n --low: #F03603;\n --blue: #4DA9E4;\n --black: #100D0D;\n --gray: #514F4F;\n --border: #edebeb;\n --bg: #F9F6F4;\n --card: #ffffff;\n --text: #100D0D;\n --muted: #514F4F;\n }\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; font-size: 16px; }\n .container { max-width: 1060px; margin: 0 auto; padding: 2rem 1.5rem; }\n\n /* Header */\n header { margin-bottom: 2rem; display: flex; align-items: center; justify-content: space-between; }\n .header-left h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; color: var(--black); }\n .header-left .meta { color: var(--muted); font-size: 0.875rem; }\n\n /* Summary bar */\n .summary { display: flex; gap: 0.75rem; margin-bottom: 2rem; flex-wrap: wrap; }\n .stat { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 1rem 1.25rem; flex: 1; min-width: 120px; }\n .stat .label { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; margin-bottom: 0.25rem; }\n .stat .value { font-size: 1.5rem; font-weight: 700; color: var(--black); }\n .stat .value.high { color: var(--high); }\n .stat .value.medium { color: var(--medium); }\n .stat .value.low { color: var(--low); }\n\n /* Score distribution bar */\n .score-bar { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 1rem 1.25rem; margin-bottom: 2rem; }\n .score-bar .bar-track { height: 8px; background: #f6f5f5; border-radius: 4px; overflow: hidden; margin-top: 0.5rem; display: flex; }\n .score-bar .bar-segment { height: 100%; }\n .score-bar .bar-segment.high { background: var(--high); }\n .score-bar .bar-segment.medium { background: var(--medium); }\n .score-bar .bar-segment.low { background: var(--low); }\n .score-bar .legend { display: flex; gap: 1.5rem; margin-top: 0.5rem; font-size: 0.75rem; color: var(--muted); }\n .score-bar .legend span::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }\n .score-bar .legend .l-high::before { background: var(--high); }\n .score-bar .legend .l-medium::before { background: var(--medium); }\n .score-bar .legend .l-low::before { background: var(--low); }\n\n /* Table */\n .results-table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; margin-bottom: 2rem; }\n .results-table th { text-align: left; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); background: #fafafa; }\n .results-table td { padding: 0.625rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.875rem; vertical-align: top; }\n .results-table tr:last-child td { border-bottom: none; }\n .results-table tr:hover { background: #fdfcfb; }\n .results-table a { color: var(--brand); text-decoration: none; font-weight: 500; }\n .results-table a:hover { text-decoration: underline; }\n\n /* Score badge */\n .score { display: inline-block; font-size: 0.8125rem; font-weight: 700; padding: 2px 10px; border-radius: 2px; min-width: 32px; text-align: center; }\n .score.high { background: rgba(144,201,77,0.12); color: #5a8a1a; border: 1px solid rgba(144,201,77,0.3); }\n .score.medium { background: rgba(244,186,65,0.12); color: #9a7520; border: 1px solid rgba(244,186,65,0.3); }\n .score.low { background: rgba(240,54,3,0.08); color: var(--low); border: 1px solid rgba(240,54,3,0.2); }\n\n /* Reasoning text */\n .reasoning { color: var(--muted); font-size: 0.8125rem; max-width: 300px; }\n\n /* Footer */\n footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: center; gap: 0.5rem; font-size: 0.75rem; color: var(--muted); }\n footer a { color: var(--brand); text-decoration: none; font-weight: 500; }\n footer a:hover { text-decoration: underline; }\n\u003c/style>\n\u003c/head>\n\u003cbody>\n\n\u003cdiv class=\"container\">\n \u003cheader>\n \u003cdiv class=\"header-left\">\n \u003ch1>{{TITLE}}\u003c/h1>\n \u003cdiv class=\"meta\">{{META}}\u003c/div>\n \u003c/div>\n \u003ca href=\"https://browserbase.com\" target=\"_blank\" rel=\"noopener\" style=\"display:flex;align-items:center;gap:0.5rem;text-decoration:none;color:var(--muted);font-size:0.8125rem;font-weight:500;\">\n \u003cspan>Powered by Browserbase\u003c/span>\n \u003csvg width=\"32\" height=\"32\" viewBox=\"0 0 100 100\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\u003crect width=\"100\" height=\"100\" rx=\"8\" fill=\"#F03603\"/>\u003cpath d=\"M36 72.2222V27.7778H51.2381C57.5873 27.7778 62.6667 32.8571 62.6667 39.2063V41.746C62.6667 44.6667 61.5873 47.3968 59.7461 49.3651C62.2858 51.4603 63.9366 54.6349 63.9366 58.254V60.7936C63.9366 67.1428 58.8572 72.2222 52.508 72.2222H36ZM42.3493 65.873H52.508C55.3651 65.873 57.5873 63.6508 57.5873 60.7936V58.254C57.5873 55.3968 55.3651 53.1746 52.508 53.1746H42.3493V65.873ZM42.3493 46.8254H51.2381C54.0953 46.8254 56.3175 44.6032 56.3175 41.746V39.2063C56.3175 36.3492 54.0953 34.127 51.2381 34.127H42.3493V46.8254Z\" fill=\"white\"/>\u003c/svg>\n \u003c/a>\n \u003c/header>\n\n \u003cdiv class=\"summary\">\n \u003cdiv class=\"stat\">\u003cdiv class=\"label\">Companies\u003c/div>\u003cdiv class=\"value\">{{TOTAL}}\u003c/div>\u003c/div>\n \u003cdiv class=\"stat\">\u003cdiv class=\"label\">Strong Fit (8-10)\u003c/div>\u003cdiv class=\"value high\">{{HIGH_COUNT}}\u003c/div>\u003c/div>\n \u003cdiv class=\"stat\">\u003cdiv class=\"label\">Partial Fit (5-7)\u003c/div>\u003cdiv class=\"value medium\">{{MEDIUM_COUNT}}\u003c/div>\u003c/div>\n \u003cdiv class=\"stat\">\u003cdiv class=\"label\">Weak Fit (1-4)\u003c/div>\u003cdiv class=\"value low\">{{LOW_COUNT}}\u003c/div>\u003c/div>\n \u003c/div>\n\n \u003cdiv class=\"score-bar\">\n \u003cdiv style=\"display:flex;justify-content:space-between;align-items:baseline;\">\n \u003cspan style=\"font-size:0.875rem;font-weight:500;\">Score Distribution\u003c/span>\n \u003cspan style=\"font-size:0.875rem;font-weight:600;color:var(--high);\">{{HIGH_PCT}}% strong fit\u003c/span>\n \u003c/div>\n \u003cdiv class=\"bar-track\">\n \u003cdiv class=\"bar-segment high\" style=\"width:{{HIGH_PCT}}%\">\u003c/div>\n \u003cdiv class=\"bar-segment medium\" style=\"width:{{MEDIUM_PCT}}%\">\u003c/div>\n \u003cdiv class=\"bar-segment low\" style=\"width:{{LOW_PCT}}%\">\u003c/div>\n \u003c/div>\n \u003cdiv class=\"legend\">\n \u003cspan class=\"l-high\">Strong (8-10)\u003c/span>\n \u003cspan class=\"l-medium\">Partial (5-7)\u003c/span>\n \u003cspan class=\"l-low\">Weak (1-4)\u003c/span>\n \u003c/div>\n \u003c/div>\n\n \u003ctable class=\"results-table\">\n \u003cthead>\n \u003ctr>\n \u003cth>Score\u003c/th>\n \u003cth>Company\u003c/th>\n \u003cth>Product\u003c/th>\n \u003cth>Industry\u003c/th>\n \u003cth>Fit Reasoning\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n {{TABLE_ROWS}}\n \u003c/tbody>\n \u003c/table>\n\u003c/div>\n\n\u003cfooter>\n \u003csvg width=\"16\" height=\"16\" viewBox=\"0 0 100 100\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\u003crect width=\"100\" height=\"100\" rx=\"8\" fill=\"#F03603\"/>\u003cpath d=\"M36 72.2222V27.7778H51.2381C57.5873 27.7778 62.6667 32.8571 62.6667 39.2063V41.746C62.6667 44.6667 61.5873 47.3968 59.7461 49.3651C62.2858 51.4603 63.9366 54.6349 63.9366 58.254V60.7936C63.9366 67.1428 58.8572 72.2222 52.508 72.2222H36ZM42.3493 65.873H52.508C55.3651 65.873 57.5873 63.6508 57.5873 60.7936V58.254C57.5873 55.3968 55.3651 53.1746 52.508 53.1746H42.3493V65.873ZM42.3493 46.8254H51.2381C54.0953 46.8254 56.3175 44.6032 56.3175 41.746V39.2063C56.3175 36.3492 54.0953 34.127 51.2381 34.127H42.3493V46.8254Z\" fill=\"white\"/>\u003c/svg>\n Generated by \u003ca href=\"https://github.com/anthropics/skills\">company-research\u003c/a> · Powered by \u003ca href=\"https://browserbase.com\">Browserbase\u003c/a>\n\u003c/footer>\n\n\u003c/body>\n\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":8130,"content_sha256":"954961902cb4e14489c7a3e39b106f1e3b7a397b3f721976aa7760b8974976ca"},{"filename":"references/research-patterns.md","content":"\u003c!-- Plan→Research→Synthesize pattern adapted from company-research v1.1.0 (2026-04-25). -->\n\u003c!-- Source: /Users/jay/skills/skills/company-research/references/research-patterns.md -->\n\u003c!-- Keep in sync if the canonical pattern there changes meaningfully. -->\n\n# Event-Prospecting — Research Patterns\n\n## Contents\n- [Plan→Research→Synthesize](#planresearchsynthesize) — canonical pattern (verbatim from company-research)\n- [Self-Research (User's Company)](#self-research-users-company) — done by company-research, consumed here as a profile\n- [Target Company Research](#target-company-research) — sub-question templates\n- [Finding Format](#finding-format) — schema for accumulated facts\n- [Research Loop Rules](#research-loop-rules) — how to stop hallucinating\n- [Depth Mode Behavior](#depth-mode-behavior) — quick / deep / deeper\n- [Synthesis Instructions](#synthesis-instructions) — turn findings into frontmatter\n- [ICP Triage (Step 5 — fast pass)](#icp-triage-step-5--fast-pass) — event-specific\n- [Deep Research (Step 7 — full pass)](#deep-research-step-7--full-pass) — event-specific\n- [Person Enrichment (Step 8 — speakers at ICP fits only)](#person-enrichment-step-8--speakers-at-icp-fits-only) — event-specific\n\n---\n\n## Plan→Research→Synthesize\n\nThis reference defines two research contexts:\n1. **Self-Research** — Deep research on the user's own company to build a strong ICP foundation. (For event-prospecting, this is done once by `company-research` and persisted in `profiles/{slug}.json`. Event-prospecting reads the profile at Step 1.)\n2. **Target Research** — Research each ICP-fit company using Plan→Research→Synthesize.\n\nBoth use the same 3-phase pattern but with different sub-questions and goals.\n\n## Self-Research (User's Company)\n\nThis is the most important research in the pipeline. Every downstream decision depends on it.\n\n### Sub-Questions\n- \"What does {company} sell and what specific problem does it solve?\"\n- \"Who are {company}'s existing customers? What industries, company sizes, and use cases?\"\n- \"Who are {company}'s competitors and what differentiates them?\"\n- \"What pricing model does {company} use and who is the typical buyer persona?\"\n- \"What use cases and pain points does {company}'s marketing emphasize?\"\n\n### Page Discovery\nDiscover site pages dynamically — do NOT hardcode paths like `/about` or `/customers`:\n1. Fetch `browse cloud fetch --allow-redirects \"{company website}/sitemap.xml\"` — primary source, has ALL pages\n2. Scan sitemap URLs for keywords: `customer`, `case-stud`, `pricing`, `about`, `use-case`, `blog`, `docs`, `industry`, `solution`\n3. Optionally fetch `browse cloud fetch --allow-redirects \"{company website}/llms.txt\"` for page descriptions\n4. Pick the 3-5 most relevant URLs from the sitemap and fetch those\n5. Sitemap is the source of truth. llms.txt is bonus context but often incomplete.\n\n### External Research\n- Search: `\"{company} customers use cases reviews\"`\n- Search: `\"{company} alternatives competitors vs\"`\n- Fetch 1-2 of the most informative third-party results (G2, blog posts, comparisons)\n\n### Synthesis Output\nFrom all findings, produce a company profile:\n- **Company**: name\n- **Product**: what they sell, how it works, key capabilities (2-3 sentences, specific)\n- **Existing Customers**: named customers or customer types found\n- **Competitors**: who they compete with, key differentiators\n- **Use Cases**: broad list of use cases the product serves (NOT tied to one vertical)\n\nDo NOT include ICP, pitch angle, or sub-verticals in the profile. Those are per-run targeting decisions made in Step 2 after the profile is confirmed. The profile is a general-purpose company fact sheet that works regardless of which vertical you target next.\n\n### Why This Matters\nA thin profile produces generic search queries, weak lead scoring, and cookie-cutter emails. A rich profile with specific customers, competitors, and use cases produces targeted queries, accurate scoring, and emails that reference real pain points.\n\n---\n\n## Target Company Research\n\n### Sub-Question Templates\n\nGenerate sub-questions from these categories based on the ICP and enrichment fields requested. Not every category applies to every company — pick the most relevant.\n\n### Priority 1 (Always ask)\n- **Product/Market**: \"What does {company} sell and who are their customers?\"\n- **ICP Fit**: \"How does {company}'s product/market relate to {sender's ICP description}?\"\n\n### Priority 2 (Ask in deep/deeper)\n- **Tech Stack**: \"What technologies, frameworks, or infrastructure does {company} use?\"\n- **Growth Signals**: \"Has {company} raised funding, launched products, or expanded recently?\"\n- **Pain Points**: \"What challenges might {company} face that {sender's product} addresses?\"\n\n### Priority 3 (Ask in deeper only)\n- **Decision Makers**: \"Who leads engineering, product, or growth at {company}?\"\n- **Competitive Landscape**: \"Who are {company}'s competitors and how are they differentiated?\"\n- **Customers/Case Studies**: \"Who are {company}'s notable customers and what results do they highlight?\"\n\n### Search Query Patterns\n\nFor each sub-question, generate 2-3 search query variations:\n\n```\n# Product/Market\n\"{company name} what they do\"\n\"{company name} product features customers\"\n\n# Tech Stack\n\"{company name} tech stack engineering blog\"\n\"{company name} careers software engineer\" (job posts reveal stack)\n\n# Growth Signals\n\"{company name} funding round 2025 2026\"\n\"{company name} launch announcement\"\n\"{company name} hiring\"\n\n# Pain Points\n\"{company name} challenges {relevant domain}\"\n\"{company name} {problem sender solves}\"\n\n# Decision Makers\n\"{company name} VP engineering CTO LinkedIn\"\n\"{company name} head of growth product\"\n```\n\n## Finding Format\n\nEach finding is a self-contained factual statement tied to a source:\n\n```json\n{\n \"subQuestion\": \"What does Acme sell and who are their customers?\",\n \"fact\": \"Acme provides checkout optimization for Shopify stores, serving mid-market DTC brands with $5M-$50M revenue\",\n \"sourceUrl\": \"https://acme.com/about\",\n \"sourceTitle\": \"About Acme - Checkout Optimization\",\n \"confidence\": \"high\"\n}\n```\n\n**Confidence levels**:\n- `high`: Directly stated on the company's own website or official press\n- `medium`: Inferred from job postings, third-party articles, or indirect signals\n- `low`: Speculative based on industry/category, or from outdated sources\n\n## Research Loop Rules\n\n1. **Process sub-questions by priority** — Priority 1 first, then 2, then 3\n2. **3-5 findings per sub-question, then move on** — Don't exhaust a topic\n3. **Use parallel tool calls** — Search multiple queries simultaneously when possible\n4. **Rephrase, don't retry** — If a search returns poor results, try different keywords\n5. **Fetch selectively** — Don't fetch every URL from search results. Pick the 1-2 most relevant based on title and URL\n6. **Stop at step limit** — Respect the depth mode's step budget per company\n7. **Homepage first** — Always fetch the company's homepage before branching to other pages\n8. **Deduplicate findings** — Don't record the same fact twice from different sources\n\n### Logo / customer-relationship direction (CRITICAL)\n\nA logo on a company's homepage carries no implicit direction. Do NOT infer a buyer/seller relationship from logo placement alone — the direction is the opposite of what you'd guess in many cases.\n\n- If `{TARGET}`'s homepage shows **`{USER_COMPANY}`'s logo** in a \"trusted by\" / \"customers\" / \"loved by\" / \"powering\" section, then **the user is the target's customer**, not the other way around. (Example: Browserbase's logo on Clerk's homepage means Browserbase uses Clerk for auth — Clerk is NOT a Browserbase customer.)\n- If `{USER_COMPANY}`'s homepage shows the target's logo in a \"customers\" section, then the target is the user's customer.\n- If neither homepage carries the other's logo, do NOT claim any customer relationship.\n- Search results that say \"X uses Y\" or \"X integrates Y\" are stronger evidence than logos. Quote the source phrase.\n- When unsure of direction, write the relationship neutrally: \"Browserbase and Clerk both serve dev-tools/agent ICP — possible co-marketing fit\" — NOT \"Clerk is a Browserbase customer\".\n\nThe user's profile (`profiles/{slug}.json`) lists `existing_customers`. **Only treat a target as an existing customer if its name appears in that array.** Logos and assumptions don't qualify.\n\n## Depth Mode Behavior\n\n### Quick Mode (100+ leads)\n- **Skip Phase A** — No sub-question decomposition\n- **Phase B**: Fetch the company homepage. Run 1-2 supplementary searches if homepage data is thin.\n- **Phase C**: Extract available data, score ICP, write email from what's available\n- **Budget**: 2-3 total tool calls per company\n- **Trade-off**: Fast and cheap, but emails may be less personalized\n\n### Deep Mode (25-50 leads)\n- **Phase A**: Decompose into 2-3 sub-questions (Priority 1 + selected Priority 2)\n- **Phase B**: For each sub-question, run 2-3 searches + fetch 1-2 URLs. Target 3-5 findings per sub-question.\n- **Phase C**: Synthesize from all findings. ICP reasoning references specific evidence. Email uses the most specific/compelling finding.\n- **Budget**: 5-8 total tool calls per company\n- **Trade-off**: Good balance of depth and scale\n\n### Deeper Mode (10-25 leads)\n- **Phase A**: Decompose into 4-5 sub-questions (Priority 1 + 2 + selected Priority 3)\n- **Phase B**: Research exhaustively. Fetch multiple pages per company (homepage, about, blog, careers, product pages). Target 3-5 findings per sub-question.\n- **Phase C**: Synthesize with cited evidence. ICP reasoning is detailed. Email references multiple specific signals.\n- **Budget**: 10-15 total tool calls per company\n- **Trade-off**: High quality intelligence, but slow and expensive\n\n## Synthesis Instructions\n\nAfter the research loop completes for a company, synthesize findings into the output record:\n\n### ICP Scoring\nScore 1-10 using ALL accumulated findings as evidence:\n- **8-10**: Strong match. Multiple high-confidence findings confirm right industry, company stage, and clear pain point alignment. The pitch angle directly addresses a visible need supported by evidence.\n- **5-7**: Partial match. Some findings suggest relevance but key signals are missing or low-confidence. Adjacent industry or unclear pain point.\n- **1-4**: Weak match. Findings indicate wrong segment, too large/small, or no apparent connection to sender's product.\n\nWrite `icp_fit_reasoning` referencing specific findings: \"Series A fintech (from Crunchbase), uses Selenium for scraping (from job posting), expanding to EU market (from blog) — strong fit for browser infrastructure.\"\n\n### Email Personalization\nUse the **richest, most specific** findings for email context:\n- Opening: Use the most concrete finding (a specific product feature, a recent launch, a job posting)\n- Bridge: Connect a finding about their challenges/stack to the sender's pitch angle\n- If only low-confidence findings exist, keep the email shorter and more general — don't fabricate specificity\n\n### Enrichment Fields\nMap findings to enrichment fields:\n- `product_description` → from Product/Market findings\n- `industry` → inferred from Product/Market\n- `employee_estimate` → from LinkedIn search or careers page findings\n- `funding_info` → from Growth Signals findings\n- `headquarters` → from company homepage or about page\n- `target_audience` → from Product/Market findings\n- `key_features` → from product page findings\n\nIf a field has no supporting findings, leave it empty rather than guessing.\n\n### Anti-Hallucination Rules\n\nApply these at synthesis time. They exist because the failure mode — especially on Framer/Next.js landing pages with little server-rendered copy — is for the subagent to pattern-match visual cues onto the sender's ICP and fabricate a plausible-sounding description:\n\n1. **Typography is not a product.** Never infer `product_description`, `industry`, or `target_audience` from fonts, design system, framework choice (Framer, Next.js, React), or site polish. \"Framer-built\" and \"uses Geist Mono\" are observations about tooling, not signals of what the company sells.\n2. **No ICP leakage.** If the homepage is thin and external search turns up nothing, do NOT default the target's description toward the sender's ICP. Manufacturing AI ≠ browser automation just because both use AI.\n3. **Quote, don't paraphrase from memory.** `product_description` must quote or closely paraphrase a specific phrase from `extract_page.mjs` output (TITLE / META_DESCRIPTION / OG_DESCRIPTION / HEADINGS / BODY) or from an external search result. If no such phrase exists, write `Unknown — homepage content not accessible`.\n4. **Cap scores on thin evidence.** If `product_description` is `Unknown`, set `icp_fit_score` ≤ 3 and `icp_fit_reasoning: Insufficient evidence — homepage returned no readable content`. Do not justify a higher score on inferred signals alone.\n\n---\n\n## ICP Triage (Step 5 — fast pass)\n\nFor each company in `seed_companies.txt`, run ONE tool call to fetch the homepage + extract a 1-line product description, then score against the ICP. Output goes to `companies/{slug}.md` with frontmatter:\n\n```yaml\ncompany_name: OpenAI\nwebsite: https://openai.com\nproduct_description: \"AI lab building safe AGI for everyone\"\nicp_fit_score: 9\nicp_fit_reasoning: \"AI agents need cloud browser infrastructure at scale; ChatGPT Agent shipped Mar 2026\"\ntriage_only: true # NOT yet deep-researched\nevent_context: \"Stripe Sessions 2026 — featured speaker on AI track\"\n```\n\nCompanies with `icp_fit_score \u003c {threshold}` (default 6) stay as triage stubs and never get deep-researched. Companies above the threshold advance to Step 7.\n\n**Hard cap: 1 tool call per company.** The only allowed call is `node {SKILL_DIR}/scripts/extract_page.mjs \"{company_homepage}\"`. Anti-hallucination rule applies in full: if the homepage is JS-rendered and `extract_page.mjs` returns empty BODY, write `product_description: Unknown — homepage content not accessible` and cap the score at 3. Do NOT do a second search to \"save\" the company — the budget is one call.\n\nThe triage subagent batches its 10 `extract_page.mjs` calls and 10 heredoc writes into a SINGLE Bash invocation using `&&` chaining and pipe-separated heredocs. One Bash call = one permission prompt.\n\n## Deep Research (Step 7 — full pass)\n\nIdentical to company-research's target research. The ICP-fit companies (typically 20-40% of the seed list) get the full Plan→Research→Synthesize treatment with sub-questions tailored to the event context.\n\n**Hard cap: 5 tool calls per company.** Budget breakdown for deep mode:\n- 1 call: `extract_page.mjs` on the homepage (re-extract; the triage version was scraped down to a 1-liner)\n- 2-3 calls: `browse cloud search` for sub-questions from Priority 1 + 2 (product, tech stack, growth signals)\n- 1-2 calls: `extract_page.mjs` on the most relevant search results (case study, blog post, careers page)\n\nEvent-context tweaks the sub-questions. Instead of generic \"What does {company} do?\", the subagent asks \"What is {company} doing with browser automation that's relevant to **Stripe Sessions' agent track**?\" — the event name and any track/topic info from `recon.json` is woven into Priority 2 sub-questions.\n\nThe deep-research subagent OVERWRITES the triage stub with the richer file (frontmatter `triage_only: false`). The compile step looks at `triage_only` to decide rendering.\n\n## Person Enrichment (Step 8 — speakers at ICP fits only)\n\nPer person at an ICP-fit company:\n- `browse cloud search \"{name} {company} linkedin\"` — verify role + harvest LinkedIn URL (always)\n- `browse cloud search \"{name} podcast OR talk OR blog 2026\"` — last 6 months for hooks (deep+)\n- `browse cloud search \"{name} github\"` — open-source signal (deeper)\n- `browse cloud search \"{name} site:x.com OR site:twitter.com\"` — best-effort recent posts (deeper)\n\n**Hard cap: 4 tool calls per person.** Deep mode runs lanes 1-2 (max 2 calls). Deeper mode runs lanes 1-4 (max 4 calls). Quick mode skips Step 8 entirely.\n\nEach person yields a `people/{slug}.md` with frontmatter:\n\n```yaml\nname: Greg Brockman\nslug: greg-brockman\ncompany: OpenAI\ntitle: President & Co-founder\nlinks:\n linkedin: https://www.linkedin.com/in/thegdb/\n x: https://x.com/gdb\n github: https://github.com/gdb\n blog: null\n podcast: https://lexfridman.com/greg-brockman/\nhook: \"Recent Lex Fridman interview on agent reliability — direct fit for browser-infra durability story\"\ndm_opener: \"Hey Greg — saw your Lex Fridman convo on agent reliability...\"\nrole_reason: \"Co-founder, sets infra direction\"\nevent_name: \"Stripe Sessions 2026\"\nevent_context: \"Panelist on Agents track\"\nicp_fit_score: 9 # inherited from companies/openai.md\n```\n\nThe `dm_opener` is 2-3 sentences, references the `hook`, names a Browserbase-style tie-in (or whatever the user's product wedge is from the profile), and ends with a soft CTA. It's what the AE pastes into LinkedIn. Generated from accumulated findings — never from memory or visual inference.\n\nThe `hook` source priority (run sequentially, stop at first hit):\n1. **Event-context**: their talk title or panel topic at this event (always available, lowest-effort, often best for cold opener)\n2. **Recent activity** (last 6 months): podcast / talk / blog / GitHub / LinkedIn post — surfaced by lanes 2-4\n3. **Company-context**: signal from their company's recent news (funding, product launch) — pulled from the `companies/{slug}.md` deep-research file\n\nIf lane 1 succeeds, the subagent can skip lanes 2-4 in deep mode. In deeper mode, run all four lanes regardless to give the report richer link pills.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17752,"content_sha256":"ff2674eb7b04d9e0a559a47c67ec8916a84434d00a22271d26f3acb3331b5d85"},{"filename":"references/workflow.md","content":"# Event-Prospecting Workflow\n\nSubagent prompt templates and tool-call governance for every fan-out step in the pipeline. The main agent in `SKILL.md` dispatches Agent batches that load these prompts; each subagent must obey the HARD TOOL-CALL CAPS below or the run is invalidated.\n\n## Contents\n- [Discovery](#discovery) — recon + extract (NOT fanned out; main agent runs these directly)\n- [ICP Triage](#icp-triage) — fast company-level scoring (1 call/company hard cap)\n- [Deep Research](#deep-research) — full Plan→Research→Synthesize on ICP fits (5 calls/company hard cap)\n- [Person Enrichment](#person-enrichment) — speakers at ICP-fit companies (4 calls/person hard cap)\n- [Compilation](#compilation) — HTML + CSV via `compile_report.mjs`\n- [Wave Management](#wave-management) — sizing, parallelism, error handling\n\n---\n\n## Discovery\n\nRecon + extract are deterministic single-process scripts run by the main agent. NOT fanned out. See SKILL.md Steps 2-4 for the orchestrator commands. This section exists only to document the artifacts the downstream subagents consume:\n\n- `{OUTPUT_DIR}/recon.json` — platform + extraction strategy (read by `extract_event.mjs`)\n- `{OUTPUT_DIR}/people.jsonl` — one JSON-encoded speaker per line (read by Step 8 batching)\n- `{OUTPUT_DIR}/seed_companies.txt` — deduped, sorted company names (read by Step 5 batching)\n\n---\n\n## ICP Triage\n\n**HARD TOOL-CALL CAP: 1 tool call per company.** The only allowed call is `extract_page.mjs` on the company homepage. NO follow-up searches, NO sitemap discovery, NO secondary fetches. If the homepage returns thin content, write `Unknown` and cap the score at 3 — that is the correct behavior, not a failure.\n\n**ENFORCEMENT** — at the start of every Bash call, prepend a comment like `# browse call N/1` so the cap is visible in tool output. If a subagent emits more than `K` calls for a batch of `K` companies, the main agent's compile step will detect the over-budget run from the call log and flag it.\n\n**Subagent prompt template** — substitute the curly-brace placeholders before dispatching:\n\n```\nYou are an ICP triage subagent for the event-prospecting skill. For each company in your batch, run ONE tool call to fetch the homepage, then score it against the user's ICP and write a triage stub to {OUTPUT_DIR}/companies/{slug}.md.\n\nCONTEXT:\n- User's company: {USER_COMPANY}\n- User's product: {USER_PRODUCT}\n- ICP description: {ICP_DESCRIPTION}\n- Event name: {EVENT_NAME}\n- Output directory: {OUTPUT_DIR} ← write company files HERE, full literal path\n\nCOMPANIES TO TRIAGE (one per line — `name|guessed_homepage|slug`):\n{COMPANY_LIST}\n\nThe guessed_homepage is a heuristic (`https://{lowercased company name without spaces}.com`). For most companies it's correct. For a few it 404s — that's expected and the fallback is documented in rule 3 below.\n\nThe slug is the canonical filename to write to: `{OUTPUT_DIR}/companies/{slug}.md`. Use it verbatim — do not re-slugify the name yourself or you'll create duplicate files.\n\nTOOL RULES — CRITICAL, FOLLOW EXACTLY:\n1. You may ONLY use the Bash tool. No exceptions.\n2. The ONLY allowed extraction call is:\n node {SKILL_DIR}/scripts/extract_page.mjs \"\u003chomepage_url>\" --max-chars 2000\n3. HARD TOOL-CALL CAP: ONE call per company. If a homepage returns FETCH_OK: false with empty BODY (e.g. the guessed URL 404s), write product_description: \"Unknown — homepage content not accessible\" and cap icp_fit_score at 3. DO NOT attempt a second call to \"save\" the company.\n4. ENFORCEMENT — at the start of EVERY Bash call, prepend a comment like `# browse call N/{TOTAL}` where N counts up and TOTAL is the number of companies in your batch. Example for a 10-company batch:\n # browse call 1/10\n node {SKILL_DIR}/scripts/extract_page.mjs \"https://openai.com\" --max-chars 2000\n5. BANNED TOOLS: WebFetch, WebSearch, Write, Read, Glob, Grep — ALL BANNED. Use ONLY Bash.\n6. NEVER use ~ or $HOME — full literal paths only.\n\nANTI-HALLUCINATION RULES:\n- NEVER infer product_description from fonts, framework, or design system. Typography is not a product.\n- NEVER let the user's ICP leak into the target's description. If you don't know what the target does, write \"Unknown\".\n- product_description MUST quote or closely paraphrase a phrase from extract_page.mjs output (TITLE / META_DESCRIPTION / OG_DESCRIPTION / HEADINGS / BODY). If none yield a recognizable product statement, write \"Unknown — homepage content not accessible\" and cap icp_fit_score at 3.\n\nICP SCORING RUBRIC (event-aware):\n- 8-10: Strong match. Homepage clearly states a product/audience that aligns with {ICP_DESCRIPTION}. Bonus if their event presence (talk topic, sponsor tier) suggests they're working in the user's wedge.\n- 5-7: Partial match. Adjacent industry, OR clear product but unclear pain-point alignment.\n- 1-4: Weak match. Wrong segment, or homepage too thin to assess (cap at 3 if Unknown).\n\nOUTPUT — write ALL company files in a SINGLE Bash call using chained heredocs:\n\n# browse call 1/{TOTAL}\nnode {SKILL_DIR}/scripts/extract_page.mjs \"{url1}\" --max-chars 2000 && \\\n# browse call 2/{TOTAL}\nnode {SKILL_DIR}/scripts/extract_page.mjs \"{url2}\" --max-chars 2000 && \\\n... && \\\ncat \u003c\u003c 'COMPANY_MD' > {OUTPUT_DIR}/companies/{slug1}.md\n---\ncompany_name: {name1}\nwebsite: {url1}\nproduct_description: {description1}\nicp_fit_score: {score1}\nicp_fit_reasoning: {reasoning1}\ntriage_only: true\nevent_context: {EVENT_NAME} — {how they show up at the event, e.g. \"speaker on Agents track\"}\n---\n\n## Triage Notes\n{1-2 sentences citing the homepage phrase that drove the score}\nCOMPANY_MD\ncat \u003c\u003c 'COMPANY_MD' > {OUTPUT_DIR}/companies/{slug2}.md\n---\n...\n---\n\n...\nCOMPANY_MD\n\nUse 'COMPANY_MD' (quoted) as the heredoc delimiter to prevent shell variable expansion.\n\nReport back ONLY: \"ICP triage batch: {scored}/{total} companies, score distribution: high={N} mid={N} low={N}\".\nDo NOT return raw homepage content or per-company reasoning to the main conversation.\n```\n\n---\n\n## Deep Research\n\n**HARD TOOL-CALL CAP: 5 tool calls per company.** Budget breakdown:\n- 1 call: `extract_page.mjs` on the homepage\n- 2-3 calls: `browse cloud search` for sub-questions (Priority 1 + selected Priority 2)\n- 1-2 calls: `extract_page.mjs` on the most relevant search results (case study / blog / careers)\n\n**ENFORCEMENT** — at the start of every Bash call, prepend `# browse call N/{TOTAL}` where TOTAL is `5 × batch_size`. A 5-company batch caps at 25 total tool calls. The main agent's compile step monitors this from the call log.\n\n**Subagent prompt template**:\n\n```\nYou are a deep-research subagent for the event-prospecting skill. For each ICP-fit company in your batch, follow the Plan→Research→Synthesize pattern from references/research-patterns.md and OVERWRITE the existing triage stub at {OUTPUT_DIR}/companies/{slug}.md with the deep-research version.\n\nCONTEXT:\n- User's company: {USER_COMPANY}\n- User's product: {USER_PRODUCT}\n- ICP description: {ICP_DESCRIPTION}\n- Event name: {EVENT_NAME}\n- Event context: {EVENT_CONTEXT} ← e.g. \"AI track / Agents / Infra\"\n- Output directory: {OUTPUT_DIR}\n\nCOMPANIES TO RESEARCH (one per line, slug|website format):\n{COMPANY_LIST}\n\nTOOL RULES — CRITICAL:\n1. You may ONLY use the Bash tool. No exceptions.\n2. All searches: browse cloud search \"...\" --num-results 10\n3. All page extractions: node {SKILL_DIR}/scripts/extract_page.mjs \"URL\" --max-chars 3000\n (uses `--output` to avoid the stdout JSON envelope, preserves meta tags, and falls back to browse get markdown when needed)\n DO NOT hand-roll a `browse cloud fetch | sed` pipeline. Use raw `browse cloud fetch` only for sitemap.xml / llms.txt.\n4. HARD TOOL-CALL CAP: 5 calls per company. Budget:\n 1× extract_page on homepage\n 2-3× browse cloud search on sub-questions\n 1-2× extract_page on the best search result\n DO NOT exceed 5 calls per company. If you've burned the budget, synthesize from what you have.\n5. ENFORCEMENT — at the start of EVERY Bash call, prepend a comment like `# browse call N/5 (company: {slug})`. Reset N to 1 for each company in the batch.\n6. BATCH all writes: write ALL deep-research files in a SINGLE Bash call using chained heredocs.\n7. BANNED TOOLS: WebFetch, WebSearch, Write, Read, Glob, Grep — ALL BANNED.\n8. NEVER use ~ or $HOME — full literal paths.\n\nANTI-HALLUCINATION RULES (same as research-patterns.md):\n- Typography is not a product.\n- No ICP leakage — if homepage is thin and search yields nothing, write \"Unknown\" and cap score at 3.\n- product_description MUST quote/paraphrase a phrase from extract_page.mjs output or a search result.\n- LOGO DIRECTION: a logo on a homepage does NOT establish a customer relationship. If {TARGET}'s homepage shows {USER_COMPANY}'s logo in a \"trusted by\"/\"customers\" section, the USER is the TARGET's customer — NOT the other way around. Only call a target an \"existing customer\" if its name appears in the user profile's `existing_customers` array. Otherwise describe the relationship neutrally (e.g. \"shared ecosystem\", \"possible partnership\", \"adjacent stack\").\n\nRESEARCH PATTERN per company (deep mode):\n\nPhase A — Plan:\nDecompose into 2-3 sub-questions. Always include \"What does {company} do?\" (Priority 1). Add 1-2 from Priority 2 chosen for event-context relevance. EXAMPLE for an Agents-track company:\n - \"What does {company} sell and who are their customers?\"\n - \"What is {company} doing with browser automation or AI agents that's relevant to {EVENT_NAME}'s {EVENT_CONTEXT}?\"\n - \"Has {company} raised funding, launched products, or expanded recently?\"\n\nPhase B — Research Loop:\n1. # browse call 1/5 — extract_page on homepage\n2. # browse call 2/5 — browse cloud search for Priority 1 sub-question\n3. # browse call 3/5 — browse cloud search for event-context sub-question\n4. # browse call 4/5 — extract_page on the most relevant search result\n5. # browse call 5/5 — (optional) one more search OR fetch if budget remains\nAccumulate findings: factual statement + source URL + confidence level (high/medium/low).\n\nPhase C — Synthesize:\n1. Score ICP fit 1-10 using the rubric (high-confidence findings + event relevance lift the score; thin evidence caps at 3).\n2. Fill enrichment fields: product_description, industry, target_audience, key_features, employee_estimate, funding_info, headquarters.\n3. Write event_relevance: how the company shows up at the event (speaker count, track, sponsor tier, demo).\n4. Reference specific findings in icp_fit_reasoning.\n\nOUTPUT — overwrite the triage stub. ALL files in a SINGLE Bash call.\n\n**FORMAT RULES — non-negotiable, parser breaks if violated**:\n- Every file MUST have a closing `---` line after the YAML frontmatter, BEFORE the first markdown section. Do NOT skip it.\n- All structured data goes in the YAML frontmatter (above the closing `---`). Markdown sections (`## Product`, `## Research Findings`, `## Event Relevance`) go AFTER the closing `---`.\n\ncat \u003c\u003c 'COMPANY_MD' > {OUTPUT_DIR}/companies/{slug}.md\n---\ncompany_name: {name}\nwebsite: {url}\nproduct_description: {description}\nindustry: {industry}\ntarget_audience: {audience}\nkey_features: {feature1} | {feature2} | {feature3}\nicp_fit_score: {score}\nicp_fit_reasoning: {reasoning, references findings}\nemployee_estimate: {estimate}\nfunding_info: {funding}\nheadquarters: {location}\ntriage_only: false\nevent_context: {EVENT_NAME} — {how they show up}\nevent_relevance: {speaker count, track, demo expectations}\n---\n\n## Product\n{2-3 sentences specific, sourced}\n\n## Research Findings\n- **[{confidence}]** {fact} (source: {url})\n- ...\n\n## Event Relevance\n{how this company connects to {EVENT_NAME}; pitch angle for AE conversation at the event}\nCOMPANY_MD\n\nReport back ONLY: \"Deep research batch: {researched}/{total} companies, {findings_count} total findings, avg ICP score {N.N}\".\n```\n\n---\n\n## Person Enrichment\n\n**HARD TOOL-CALL CAP: 4 tool calls per person.** Lanes:\n1. `browse cloud search \"{name} {company} linkedin\"` — always (deep + deeper)\n2. `browse cloud search \"{name} podcast OR talk OR blog 2026\"` — deep + deeper\n3. `browse cloud search \"{name} github\"` — deeper only\n4. `browse cloud search \"{name} site:x.com OR site:twitter.com\"` — deeper only, best-effort\n\nDeep mode: lanes 1-2 (max 2 calls/person). Deeper mode: lanes 1-4 (max 4 calls/person).\n\n**ENFORCEMENT** — every Bash call prepends `# browse call N/{LANES} (person: {slug})`, where LANES is 2 (deep) or 4 (deeper). Reset N to 1 for each person.\n\n**Subagent prompt template**:\n\n```\nYou are a person-enrichment subagent for the event-prospecting skill. For each person in your batch, run 2-4 browse cloud searches to harvest LinkedIn + recent activity + GitHub + X presence, generate a hook + DM opener, and write {OUTPUT_DIR}/people/{slug}.md.\n\nCONTEXT:\n- User's company: {USER_COMPANY}\n- User's product: {USER_PRODUCT}\n- ICP description: {ICP_DESCRIPTION}\n- Event name: {EVENT_NAME}\n- Depth mode: {DEPTH} ← `deep` (2 lanes) or `deeper` (4 lanes)\n- Output directory: {OUTPUT_DIR}\n\nPEOPLE TO ENRICH (one JSON record per line):\n{PEOPLE_BATCH}\n\nEach record has fields:\n { \"name\": \"...\", \"title\": \"...\", \"company\": \"...\", \"linkedin\": \"...\", \"slug\": \"...\", \"bio\": \"...\", \"image\": \"...\" }\n\nThe `image` field is the speaker's headshot URL extracted from the event site (may be null on platforms that don't expose it). PRESERVE it verbatim into the people/{slug}.md frontmatter as `image: {url}` — do NOT fetch, replace, or \"improve\" it.\n\nTOOL RULES — CRITICAL:\n1. You may ONLY use the Bash tool. No exceptions.\n2. All searches: browse cloud search \"...\" --num-results 5\n3. HARD TOOL-CALL CAP per person:\n deep mode: 2 calls (lanes 1 + 2)\n deeper mode: 4 calls (lanes 1 + 2 + 3 + 4)\n DO NOT exceed the cap. If a lane fails (no useful result), DO NOT compensate by running a fifth call.\n4. ENFORCEMENT — at the start of EVERY Bash call, prepend a comment like `# browse call N/{LANES} (person: {slug})`. Reset N to 1 for each person in the batch.\n5. BATCH all writes: write ALL people files in a SINGLE Bash call using chained heredocs.\n6. BANNED TOOLS: WebFetch, WebSearch, Write, Read, Glob, Grep — ALL BANNED.\n7. NEVER use ~ or $HOME — full literal paths.\n\nANTI-HALLUCINATION RULES:\n- A person's `hook` MUST quote or paraphrase a SPECIFIC finding from a browse cloud search result. NEVER infer from \"they look senior\" or \"their company is AI-y\".\n- If lanes 2-4 yield no public signal in the last 6 months, fall back to event-context (their talk title from the bio field). Event-context is always available and beats a fabricated hook.\n- The DM opener MUST reference the hook verbatim. If the hook is event-context, name the talk title. Do NOT name a podcast or blog post the person didn't actually appear in.\n\nLANE PROMPTS (run only the lanes for your DEPTH):\n\nLane 1 (always):\n # browse call 1/{LANES} (person: {slug})\n browse cloud search \"\\\"{name}\\\" \\\"{company}\\\" linkedin\" --num-results 5\n → harvest LinkedIn URL + verify current title\n\nLane 2 (deep + deeper):\n # browse call 2/{LANES} (person: {slug})\n browse cloud search \"\\\"{name}\\\" podcast OR talk OR blog 2026\" --num-results 5\n → harvest most-recent activity. If a podcast/blog/talk URL appears, that's a candidate hook.\n\nLane 3 (deeper only):\n # browse call 3/{LANES} (person: {slug})\n browse cloud search \"\\\"{name}\\\" github\" --num-results 5\n → harvest github.com/{handle} URL if present\n\nLane 4 (deeper only):\n # browse call 4/{LANES} (person: {slug})\n browse cloud search \"\\\"{name}\\\" site:x.com OR site:twitter.com\" --num-results 5\n → attempt to find x.com/{handle} URL + most recent post topic; leave null if no direct profile appears\n\nHOOK SOURCE PRIORITY (run sequentially, stop at first hit):\n1. Recent activity (lane 2): podcast title / blog headline / talk title from the last 6 months. THIS IS THE BEST HOOK — concrete, dated, public.\n2. Event-context: their talk title or panel topic at {EVENT_NAME} (extracted from the bio field passed in the JSON record). ALWAYS available, even when external search yields nothing.\n3. Company-context: pull from {OUTPUT_DIR}/companies/{company_slug}.md `event_relevance` line (read via `awk` from the existing file; this is allowed because the file is local, not a tool call).\n\nDM OPENER FORMAT:\n2-3 sentences:\n - sentence 1: reference the hook explicitly\n - sentence 2: 1-line wedge tie-in to {USER_PRODUCT}\n - sentence 3: soft CTA (\"worth a 15-min walkthrough?\", \"open to a quick chat?\", etc.)\nNEVER salesy. NEVER reference Browserbase by name unless the user's profile says to. Use the user's wedge framing from {USER_PRODUCT}.\n\nOUTPUT — write ALL people files in a SINGLE Bash call using chained heredocs.\n\n**FORMAT RULES — non-negotiable, parser breaks if violated**:\n- Every file MUST have a closing `---` line after the YAML frontmatter. Do NOT skip it.\n- `hook`, `dm_opener`, `role_reason` MUST be YAML frontmatter fields — NEVER markdown sections like `## Hook` or `## DM Opener`.\n- `links` MUST be a nested YAML object (`links:` then indented `linkedin:`, `x:`, etc.). NEVER flat top-level keys.\n- `dm_opener` is a YAML pipe scalar (`dm_opener: |` then indented multi-line text).\n\ncat \u003c\u003c 'PERSON_MD' > {OUTPUT_DIR}/people/{slug}.md\n---\nname: {full name}\nslug: {slug}\ncompany: {company}\ncompany_slug: {company_slug}\ntitle: {title}\nimage: {image url from input record, or null}\nlinks:\n linkedin: {url or null}\n x: {url or null}\n github: {url or null}\n blog: {url or null}\n podcast: {url or null}\nhook: {1 sentence, sourced}\ndm_opener: |\n {sentence 1: hook reference}\n {sentence 2: wedge tie-in}\n {sentence 3: soft CTA}\nrole_reason: {why this person matters at the company}\nevent_name: {EVENT_NAME}\nevent_context: {their role at the event — talk title, panel topic, sponsor employee, etc.}\nicp_fit_score: {inherited from companies/{company_slug}.md}\nicp_fit_reasoning: {inherited}\nenriched_at: {ISO timestamp}\n---\n\n## Why reach out\n- **Why the company**: {1 line, references companies/{company_slug}.md}\n- **Why the person**: {role_reason restated as 1 line}\n- **Hook**: {hook, with source URL inline}\n\n## Public links\n{bullet list of every harvested link, one per line}\n\n## Recent activity\n- **[{confidence}]** {finding} (source: {url})\n- ...\nPERSON_MD\n\nReport back ONLY: \"Person enrichment batch: {enriched}/{total} people, {hook_count_event} event-context hooks + {hook_count_recent} recent-activity hooks\".\n```\n\n---\n\n## Compilation\n\nAfter all subagents complete, the main agent runs the compile step ONCE. NOT fanned out. From SKILL.md Step 9:\n\n```bash\nnode {SKILL_DIR}/scripts/compile_report.mjs {OUTPUT_DIR} --user-company {USER_SLUG} --open\n```\n\nThe compile script:\n1. Reads every `companies/*.md` and `people/*.md`\n2. Joins people to their company files (via `company_slug` frontmatter)\n3. Sorts people by inherited `icp_fit_score` desc\n4. Renders:\n - `index.html` — person-first card grid (the primary deliverable)\n - `people.html` — filterable speaker grid (alternate view, with chips for company / role bucket / ICP band)\n - `companies.html` — ICP-ranked company table with attendees expandable per row\n - `results.csv` — flat one-row-per-person spreadsheet for cold-outbound import\n5. Opens `index.html` in the default browser (`--open` flag)\n\nThe compile step does NOT mutate any `.md` files. All HTML is generated fresh from the markdown sources every run, so re-running compile after a manual edit to a `.md` file regenerates the report.\n\n---\n\n## Wave Management\n\n### Key Principle: Maximize Parallelism, Minimize Prompts\n\nLaunch as many subagents as possible in a single Agent fan-out (up to ~6 Agent calls per message). Each subagent MUST batch all its Bash operations into a single call to minimize permission prompts. One subagent batch = one Bash call = one permission prompt.\n\n### Sizing Formula\n\n```\nseed_companies = wc -l seed_companies.txt\nicp_fits = wc -l icp_fits.txt (typically 20-40% of seed)\npeople_to_enrich = wc -l _people_to_enrich.jsonl (typically 1.5-2.5× icp_fits)\n\ntriage_subagents = ceil(seed_companies / 10) # 10 companies/subagent, 1 call each\ndeep_subagents = ceil(icp_fits / 5) # 5 companies/subagent, 5 calls each\nperson_subagents = ceil(people_to_enrich / 5) # 5 people/subagent, 2-4 calls each\n```\n\nFor Stripe Sessions (99 seed → ~30 ICP fits → ~50 people):\n- Triage: 10 subagents × 10 calls = 100 calls (matches the cost model: 99 calls)\n- Deep research: 6 subagents × 25 calls = 150 calls\n- Person enrichment: 10 subagents × ~10 calls = 100 calls\n- Total: ~350 tool calls, matches the design doc cost model.\n\n### Wave Cadence\n\nDispatch all subagents for a given step in **a single Agent fan-out message** (up to 6 per message; if more needed, run a second wave after the first completes). Do NOT serialize subagents that can run in parallel.\n\n### Error Handling\n\n- If a single subagent fails, log the error and continue. The compile step ignores missing files gracefully.\n- If >50% of subagents in a wave fail, pause and surface to the user before continuing.\n- If `extract_page.mjs` returns FETCH_OK: false with empty BODY, the triage subagent should write `product_description: Unknown — homepage content not accessible` and cap score at 3 (NOT skip the company — the file must exist for compile to render the row).\n- The HARD TOOL-CALL CAP is non-negotiable. If a subagent exceeds its budget, the run is invalidated for that batch (compile step warns; user can re-dispatch).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":21534,"content_sha256":"e4ec167bf8aa57765c279a1f28af8d62d22c57e9c324f226566e2fe34b54718d"},{"filename":"scripts/__fixtures__/stripe-snapshot.json","content":"{\n \"title\": \"Stripe Sessions 2026 | Speakers\",\n \"hasNextData\": true,\n \"nextDataLen\": 439761,\n \"speakerArrayPaths\": [\n \".props.pageProps.featuredSpeakers.speakers.items\",\n \".props.pageProps.moreSpeakers.speakers.items\"\n ],\n \"totalSpeakers\": 240\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":258,"content_sha256":"137c846b6688ba6268e121e8ef535b2b8168d6cf385ffda3185f516b581ae295"},{"filename":"scripts/compile_report.mjs","content":"#!/usr/bin/env node\n\n// Compiles per-company + per-person markdown research files into a person-first\n// HTML report (index.html), plus people.html and companies.html alternate views.\n//\n// Reads:\n// \u003cresearch-dir>/companies/*.md — one per company (frontmatter + body)\n// \u003cresearch-dir>/people/*.md — one per ICP-fit speaker (frontmatter + body)\n// \u003cresearch-dir>/*.md — also accepted at top-level (legacy / company-research format)\n//\n// Writes:\n// \u003cresearch-dir>/index.html — person-first, ICP-ranked card grid (default landing)\n// \u003cresearch-dir>/people.html — filterable people grid (chips: company, role, ICP band)\n// \u003cresearch-dir>/companies.html — ICP-ranked company table with expandable attendees\n// \u003cresearch-dir>/companies/\u003cslug>.html — individual company research pages\n// \u003cresearch-dir>/results.csv — scored spreadsheet\n//\n// Usage: node compile_report.mjs \u003cresearch-dir> [--template \u003cpath>] [--open]\n\nimport { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';\nimport { join, dirname } from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst args = process.argv.slice(2);\n\nif (args.includes('--help') || args.includes('-h') || args.length === 0) {\n console.error(`Usage: node compile_report.mjs \u003cresearch-dir> [--template \u003cpath>] [--open]\n\nReads companies/*.md and people/*.md from \u003cresearch-dir>, generates:\n - index.html — people grouped by company (ranked by company ICP)\n - people.html — filterable people list (chips: company, role, ICP band)\n - companies.html — ICP-ranked company table with expandable attendees\n - companies/\u003cslug>.html — individual company research pages\n - results.csv — scored spreadsheet\n\nOptions:\n --template \u003cpath> Path to report-template.html (default: auto-detect)\n --open Open index.html in browser after generation\n --help, -h Show this help message`);\n process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);\n}\n\nconst dir = args[0];\nconst shouldOpen = args.includes('--open');\nconst templateIdx = args.indexOf('--template');\nlet templatePath = templateIdx !== -1 ? args[templateIdx + 1] : null;\n\n// Auto-detect template\nif (!templatePath) {\n const candidates = [\n join(__dirname, '..', 'references', 'report-template.html'),\n join(__dirname, 'report-template.html'),\n ];\n templatePath = candidates.find(p => existsSync(p));\n if (!templatePath) {\n console.error('Error: Could not find report-template.html. Use --template to specify path.');\n process.exit(1);\n }\n}\n\nconst template = readFileSync(templatePath, 'utf-8');\n\n// ----- Frontmatter / body parsing (shared) ---------------------------------\n\nfunction parseFrontmatter(content) {\n // Tolerant frontmatter match: prefer closing ---, but if a subagent forgot it,\n // fall back to stopping at the first markdown heading (e.g. ## Product) so the\n // file still parses instead of vanishing from the report.\n const fmMatch = content.match(/^---\\n([\\s\\S]*?)(?:\\n---\\s*\\n|\\n(?=## ))/);\n if (!fmMatch) return null;\n const fields = {};\n const lines = fmMatch[1].split('\\n');\n let i = 0;\n while (i \u003c lines.length) {\n const line = lines[i];\n // Multi-line YAML pipe scalar: key: |\n // line one\n // line two\n const pipeMatch = line.match(/^([a-zA-Z_][\\w]*)\\s*:\\s*\\|\\s*$/);\n if (pipeMatch) {\n const key = pipeMatch[1];\n const buf = [];\n i++;\n while (i \u003c lines.length && /^\\s{2,}/.test(lines[i])) {\n buf.push(lines[i].replace(/^\\s{2}/, ''));\n i++;\n }\n fields[key] = buf.join('\\n').trim();\n continue;\n }\n // Nested block (e.g. links: with indented children)\n const nestedHeadMatch = line.match(/^([a-zA-Z_][\\w]*)\\s*:\\s*$/);\n if (nestedHeadMatch && i + 1 \u003c lines.length && /^\\s{2,}\\S/.test(lines[i + 1])) {\n const key = nestedHeadMatch[1];\n const child = {};\n i++;\n while (i \u003c lines.length && /^\\s{2,}\\S/.test(lines[i])) {\n const c = lines[i].trim();\n const idx = c.indexOf(':');\n if (idx > 0) {\n const ck = c.slice(0, idx).trim();\n const cv = c.slice(idx + 1).trim().replace(/^[\"']|[\"']$/g, '');\n child[ck] = (cv === 'null' || cv === '') ? null : cv;\n }\n i++;\n }\n fields[key] = child;\n continue;\n }\n const idx = line.indexOf(':');\n if (idx > 0) {\n const key = line.slice(0, idx).trim();\n const val = line.slice(idx + 1).trim().replace(/^[\"']|[\"']$/g, '');\n if (key) fields[key] = val;\n }\n i++;\n }\n return fields;\n}\n\nfunction parseBody(content) {\n // Mirror parseFrontmatter's tolerance — body starts after closing --- if present,\n // else at the first ## heading.\n const closed = content.match(/^---\\n[\\s\\S]*?\\n---\\s*\\n([\\s\\S]*)/);\n if (closed) return closed[1].trim();\n const fallback = content.match(/^---\\n[\\s\\S]*?\\n(## [\\s\\S]*)/);\n return fallback ? fallback[1].trim() : '';\n}\n\n// Pull a markdown section's content given its heading text. Used as a fallback when\n// person-enrichment subagents wrote hook/dm_opener/etc. as ## sections instead of YAML.\nfunction extractSection(body, heading) {\n if (!body) return null;\n const escaped = heading.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}');\n const re = new RegExp(`^##\\\\s+${escaped}\\\\s*\\\\n+([\\\\s\\\\S]*?)(?=\\\\n##\\\\s|$)`, 'im');\n const m = body.match(re);\n return m ? m[1].trim() : null;\n}\n\nfunction escapeHtml(str) {\n return (str || '').toString().replace(/&/g, '&').replace(/\u003c/g, '<').replace(/>/g, '>').replace(/\"/g, '"');\n}\n\nfunction escapeAttr(str) {\n return escapeHtml(str).replace(/\\n/g, ' ');\n}\n\n// Escape a value going into a single-quoted JS string literal that itself sits\n// inside an HTML attribute (e.g. onerror=\"...textContent:'X'...\"). JS escapes\n// have to come before HTML escapes — `escapeHtml` doesn't touch ' or \\, so a\n// name starting with `'` would otherwise close the JS string mid-attribute.\nfunction escapeJsInAttr(str) {\n const js = (str || '').toString().replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\");\n return escapeAttr(js);\n}\n\nfunction scoreClass(score) {\n const s = parseInt(score) || 0;\n if (s >= 8) return 'high';\n if (s >= 5) return 'medium';\n return 'low';\n}\n\n// Same thresholds as scoreClass + the template's \"Strong Fit (8-10) / Partial\n// Fit (5-7) / Low Fit (1-4)\" header so a score-5 company isn't simultaneously\n// counted as \"Partial\" in the summary and \"Low\" on its person card.\nfunction icpBand(score) {\n const s = parseInt(score) || 0;\n if (s >= 8) return 'high';\n if (s >= 5) return 'mid';\n return 'low';\n}\n\nfunction slugify(s) {\n return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');\n}\n\nfunction roleBucket(title) {\n const t = (title || '').toLowerCase();\n // VP/Director check must run before Founder/CXO — otherwise \"Vice President\"\n // gets caught by the \"president\" alternative and miscategorized as a CXO.\n // Two-letter tokens (pm, ui, ml, ai, ux) need \\b anchors or they substring-\n // match inside unrelated words (\"development\" → pm, \"build\" → ui, \"html\" →\n // ml, \"captain\" → ai, \"deluxe\" → ux).\n if (/(\\bvp\\b|vice president|head of|director)/.test(t)) return 'VP/Director';\n if (/(ceo|founder|co-?founder|president|chief)/.test(t)) return 'Founder/CXO';\n if (/(engineer|developer|programmer|architect|\\bsre\\b|devops)/.test(t)) return 'Engineering';\n if (/(product|\\bpm\\b)/.test(t)) return 'Product';\n if (/(design|\\bux\\b|\\bui\\b)/.test(t)) return 'Design';\n if (/(market|growth|content)/.test(t)) return 'Marketing';\n if (/(sales|account|revenue|\\bgtm\\b)/.test(t)) return 'Sales/GTM';\n if (/(research|scientist|\\bml\\b|\\bai\\b)/.test(t)) return 'Research/AI';\n return 'Other';\n}\n\nfunction mdToHtml(md) {\n const lines = md.split('\\n');\n const out = [];\n let inList = false;\n let paraLines = [];\n\n function flushPara() {\n if (paraLines.length > 0) {\n let text = escapeHtml(paraLines.join(' ').trim());\n text = text.replace(/\\*\\*\\[(\\w+)\\]\\*\\*/g, '\u003cspan class=\"confidence $1\">[$1]\u003c/span>');\n text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '\u003cstrong>$1\u003c/strong>');\n if (text) out.push(`\u003cp>${text}\u003c/p>`);\n paraLines = [];\n }\n }\n\n function closeList() {\n if (inList) { out.push('\u003c/ul>'); inList = false; }\n }\n\n for (const line of lines) {\n const trimmed = line.trim();\n\n if (!trimmed) {\n flushPara();\n closeList();\n continue;\n }\n\n if (trimmed.startsWith('## ')) {\n flushPara(); closeList();\n out.push(`\u003ch2>${escapeHtml(trimmed.slice(3))}\u003c/h2>`);\n continue;\n }\n if (trimmed.startsWith('### ')) {\n flushPara(); closeList();\n out.push(`\u003ch3>${escapeHtml(trimmed.slice(4))}\u003c/h3>`);\n continue;\n }\n\n if (trimmed.startsWith('- ')) {\n flushPara();\n if (!inList) { out.push('\u003cul>'); inList = true; }\n let text = escapeHtml(trimmed.slice(2));\n text = text.replace(/\\*\\*\\[(\\w+)\\]\\*\\*/g, '\u003cspan class=\"confidence $1\">[$1]\u003c/span>');\n text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '\u003cstrong>$1\u003c/strong>');\n out.push(`\u003cli>${text}\u003c/li>`);\n continue;\n }\n\n closeList();\n paraLines.push(trimmed);\n }\n\n flushPara();\n closeList();\n return out.join('\\n');\n}\n\n// ----- Read companies + people --------------------------------------------\n\nfunction readMdDir(p) {\n if (!existsSync(p)) return [];\n let entries = [];\n try { entries = readdirSync(p); }\n catch (e) {\n // Surface the failure — a permissions/disk issue here means the user gets\n // a partial report (e.g. people but no companies) and would otherwise\n // never know why their data was skipped.\n console.error(`[compile_report] readdir ${p} failed: ${e.message}`);\n return [];\n }\n return entries.filter(f => f.endsWith('.md')).sort().map(f => {\n const content = readFileSync(join(p, f), 'utf-8');\n const fields = parseFrontmatter(content);\n if (!fields) return null;\n const body = parseBody(content);\n const slug = f.replace('.md', '');\n return { ...fields, body, slug, file: f };\n }).filter(Boolean);\n}\n\nconst companiesDir = join(dir, 'companies');\nlet companies = readMdDir(companiesDir);\n\n// Legacy fallback: top-level *.md files = companies (company-research's format)\nif (companies.length === 0) {\n companies = readMdDir(dir);\n}\n\nconst peopleDir = join(dir, 'people');\nconst people = readMdDir(peopleDir);\n\nif (companies.length === 0 && people.length === 0) {\n console.error(`No .md files found in ${dir} (looked in companies/, people/, and top-level)`);\n process.exit(1);\n}\n\n// Sort companies by ICP score descending\ncompanies.sort((a, b) => (parseInt(b.icp_fit_score) || 0) - (parseInt(a.icp_fit_score) || 0));\n\n// Strip suffixes like \", Inc\" / \"LLC\" / \"Corp\" so \"Acme LLC\" and \"Acme\" collapse.\nfunction normalizeCompanyName(s) {\n return (s || '').toLowerCase().replace(/[,\\s]+(inc|llc|ltd|corp|co)\\.?$/i, '').trim();\n}\n\n// Deduplicate companies by normalized name\nconst seen = new Map();\nfor (const c of companies) {\n const name = normalizeCompanyName(c.company_name);\n if (!name) continue;\n if (!seen.has(name)) seen.set(name, c);\n}\nconst deduped = [...seen.values()];\n\n// Build company lookups. Iterate ALL companies (not just dedup survivors) so\n// that every variant slug + name + normalized-name maps to the surviving\n// record — otherwise people whose `p.company` matches the discarded variant\n// fall into \"Unmatched\" and lose their ICP score / grouping.\nconst companyBySlug = new Map();\nconst companyByName = new Map();\nfor (const c of companies) {\n const winner = seen.get(normalizeCompanyName(c.company_name)) || c;\n if (c.slug) companyBySlug.set(c.slug, winner);\n if (c.company_name) {\n companyByName.set(c.company_name.toLowerCase().trim(), winner);\n companyByName.set(normalizeCompanyName(c.company_name), winner);\n }\n}\n\nfunction resolveCompany(person) {\n if (person.company_slug && companyBySlug.has(person.company_slug)) return companyBySlug.get(person.company_slug);\n if (person.company) {\n const k = person.company.toLowerCase().trim();\n if (companyByName.has(k)) return companyByName.get(k);\n const norm = normalizeCompanyName(person.company);\n if (companyByName.has(norm)) return companyByName.get(norm);\n const slugGuess = slugify(person.company);\n if (companyBySlug.has(slugGuess)) return companyBySlug.get(slugGuess);\n }\n return null;\n}\n\n// Augment each person with effective company + score for sorting\nfor (const p of people) {\n const comp = resolveCompany(p);\n p._company = comp;\n // Effective ICP: company score wins (per the plan), else person frontmatter, else -1 (last)\n const cs = comp ? parseInt(comp.icp_fit_score) : NaN;\n const ps = parseInt(p.icp_fit_score);\n p._effectiveScore = !isNaN(cs) ? cs : (!isNaN(ps) ? ps : -1);\n}\n\n// Sort people by effective ICP descending; unscored last; stable on name\npeople.sort((a, b) => {\n if (b._effectiveScore !== a._effectiveScore) return b._effectiveScore - a._effectiveScore;\n return (a.name || '').localeCompare(b.name || '');\n});\n\n// ----- Stats --------------------------------------------------------------\n\nconst scores = deduped.map(c => parseInt(c.icp_fit_score) || 0);\nconst high = scores.filter(s => s >= 8).length;\nconst medium = scores.filter(s => s >= 5 && s \u003c 8).length;\nconst low = scores.filter(s => s \u003c 5).length;\nconst total = deduped.length;\nconst highPct = total > 0 ? Math.round((high / total) * 100) : 0;\nconst mediumPct = total > 0 ? Math.round((medium / total) * 100) : 0;\nconst lowPct = total > 0 ? 100 - highPct - mediumPct : 0;\n\nconst dirName = dir.split('/').filter(Boolean).pop() || 'event';\nconst title = dirName.replace(/_/g, ' ').replace(/-/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());\n\n// ----- Person card render --------------------------------------------------\n\nfunction initials(name) {\n return (name || '?').split(/\\s+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join('');\n}\n\nfunction renderPersonCard(person, company) {\n const c = company || {};\n // Tolerate two YAML shapes: { links: { linkedin, x, github, ... } } or flat top-level keys.\n const links = (person.links && typeof person.links === 'object') ? person.links : {\n linkedin: person.linkedin || null,\n x: person.x || person.twitter || null,\n github: person.github || null,\n blog: person.blog || null,\n podcast: person.podcast || null,\n };\n const linkPills = ['linkedin', 'x', 'github', 'blog', 'podcast']\n .filter(k => links[k])\n .map(k => `\u003ca class=\"link-pill link-${k}\" href=\"${escapeHtml(links[k])}\" target=\"_blank\" rel=\"noopener\">${k.toUpperCase()}\u003c/a>`)\n .join(' ');\n\n const score = c.icp_fit_score || person.icp_fit_score || '?';\n const band = icpBand(score);\n // Fall back to body sections if subagents wrote ## Hook / ## DM Opener / etc. instead of YAML fields.\n const hook = person.hook || extractSection(person.body, 'Hook') || '—';\n const roleReason = person.role_reason || extractSection(person.body, 'Why the person') || '—';\n const dmOpener = person.dm_opener || extractSection(person.body, 'DM Opener') || '';\n const photo = person.image\n ? `\u003cimg class=\"photo\" src=\"${escapeHtml(person.image)}\" alt=\"${escapeHtml(person.name || '')}\" loading=\"lazy\" referrerpolicy=\"no-referrer\" onerror=\"this.replaceWith(Object.assign(document.createElement('div'),{className:'photo photo-placeholder',textContent:'${escapeJsInAttr(initials(person.name))}'}))\">`\n : `\u003cdiv class=\"photo photo-placeholder\">${escapeHtml(initials(person.name))}\u003c/div>`;\n\n return `\u003cdiv class=\"person-card\" data-slug=\"${escapeHtml(person.slug)}\" data-company=\"${escapeHtml((person.company || '').toLowerCase())}\" data-role=\"${escapeHtml(roleBucket(person.title))}\" data-icpband=\"${band}\" data-icp-score=\"${escapeHtml(String(score))}\">\n ${photo}\n \u003cdiv class=\"card-body\">\n \u003cdiv class=\"card-header\">\n \u003ch3>${escapeHtml(person.name || person.slug)}\u003c/h3>\n \u003cspan class=\"icp-badge icp-${band}\">ICP ${escapeHtml(String(score))}\u003c/span>\n \u003c/div>\n \u003cdiv class=\"card-meta\">${escapeHtml(person.title || '')}${person.title && person.company ? ' · ' : ''}${escapeHtml(person.company || '')}\u003c/div>\n ${linkPills ? `\u003cdiv class=\"card-links\">${linkPills}\u003c/div>` : ''}\n \u003cul class=\"card-why\">\n ${roleReason && roleReason !== '—' ? `\u003cli>\u003cstrong>Why the person:\u003c/strong> ${escapeHtml(roleReason)}\u003c/li>` : ''}\n ${hook && hook !== '—' ? `\u003cli>\u003cstrong>Hook:\u003c/strong> ${escapeHtml(hook)}\u003c/li>` : ''}\n \u003c/ul>\n \u003cdiv class=\"card-actions\">\n \u003cbutton class=\"btn-copy\" data-clipboard=\"${escapeAttr(dmOpener)}\">Copy DM opener\u003c/button>\n \u003c/div>\n \u003c/div>\n \u003c/div>`;\n}\n\n// ----- Shared CSS for the new event-prospecting UI -------------------------\n\nconst eventCss = `\n .nav-bar { display:flex; gap:0.5rem; margin-bottom:1.25rem; font-size:0.875rem; }\n .nav-bar a { padding:0.4rem 0.85rem; border:1px solid var(--border); border-radius:4px; background:var(--card); color:var(--muted); font-weight:500; text-decoration:none; }\n .nav-bar a.active { background:var(--brand); color:#fff; border-color:var(--brand); }\n .filter-bar { display:flex; gap:0.75rem; flex-wrap:wrap; margin-bottom:1.25rem; align-items:center; }\n .filter-group { display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; padding:0.4rem 0.6rem; background:var(--card); border:1px solid var(--border); border-radius:4px; }\n .filter-group .label { font-size:0.7rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.05em; font-weight:600; margin-right:0.25rem; }\n .chip { display:inline-block; padding:0.2rem 0.6rem; border:1px solid var(--border); border-radius:999px; background:#fafafa; font-size:0.7rem; color:var(--muted); cursor:pointer; user-select:none; }\n .chip.active { background:var(--brand); color:#fff; border-color:var(--brand); }\n .chip:hover { border-color:var(--brand); }\n .person-grid { display:flex; flex-direction:column; gap:0.75rem; }\n .person-card { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:1rem 1.1rem; display:flex; flex-direction:row; gap:1rem; align-items:stretch; }\n .person-card.hidden { display:none; }\n .person-card .photo { width:96px; height:96px; flex:0 0 96px; border-radius:6px; object-fit:cover; background:#f0eeec; }\n .person-card .photo-placeholder { display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1.5rem; color:var(--muted); letter-spacing:0.04em; }\n .card-body { flex:1; min-width:0; display:flex; flex-direction:column; gap:0.45rem; }\n .card-header { display:flex; justify-content:space-between; align-items:flex-start; gap:0.5rem; }\n .card-header h3 { font-size:1rem; font-weight:600; color:var(--black); margin:0; }\n .company-groups { display:flex; flex-direction:column; gap:1.5rem; }\n .company-group { background:transparent; }\n .company-header { display:flex; flex-direction:column; gap:0.25rem; padding:0.5rem 0.1rem 0.75rem; border-bottom:1px solid var(--border); margin-bottom:0.75rem; }\n .company-header-row { display:flex; align-items:center; gap:0.6rem; }\n .company-header h2 { font-size:1.05rem; font-weight:600; color:var(--black); margin:0; }\n .company-header .company-meta { font-size:0.75rem; color:var(--muted); margin:0; }\n .company-header .company-fit { font-size:0.8125rem; color:var(--text); margin:0.15rem 0 0; }\n .company-header a { color:var(--brand); text-decoration:none; }\n .company-header a:hover { text-decoration:underline; }\n .company-people { display:flex; flex-direction:column; gap:0.6rem; }\n @media (max-width: 640px) {\n .person-card { flex-direction:column; }\n .person-card .photo { width:80px; height:80px; flex-basis:80px; }\n }\n .icp-badge { font-size:0.7rem; font-weight:700; padding:2px 8px; border-radius:3px; white-space:nowrap; }\n .icp-badge.icp-high { background:rgba(144,201,77,0.14); color:#5a8a1a; }\n .icp-badge.icp-mid { background:rgba(244,186,65,0.14); color:#9a7520; }\n .icp-badge.icp-low { background:rgba(240,54,3,0.10); color:var(--low); }\n .card-meta { font-size:0.8125rem; color:var(--muted); }\n .card-links { display:flex; flex-wrap:wrap; gap:0.3rem; }\n .link-pill { font-size:0.7rem; font-weight:600; padding:2px 8px; border-radius:3px; text-decoration:none; border:1px solid var(--border); color:var(--text); background:#fafafa; letter-spacing:0.04em; }\n .link-pill:hover { background:var(--brand); color:#fff; border-color:var(--brand); }\n .card-why { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:0.3rem; font-size:0.8125rem; color:var(--text); }\n .card-why li { line-height:1.45; }\n .card-why strong { color:var(--black); font-weight:600; }\n .card-actions { display:flex; gap:0.5rem; margin-top:auto; padding-top:0.5rem; }\n .card-actions button { font:inherit; font-size:0.75rem; font-weight:600; padding:0.4rem 0.7rem; border-radius:4px; border:1px solid var(--border); background:var(--card); color:var(--text); cursor:pointer; }\n .card-actions button:hover { background:var(--brand); color:#fff; border-color:var(--brand); }\n .card-actions button.copied { background:var(--high); color:#fff; border-color:var(--high); }\n details.attendees { margin-top:0.4rem; }\n details.attendees summary { cursor:pointer; color:var(--brand); font-size:0.8125rem; font-weight:500; }\n details.attendees ul { margin:0.4rem 0 0 1rem; padding:0; list-style:disc; }\n details.attendees li { font-size:0.8125rem; color:var(--text); margin-bottom:0.2rem; }\n`;\n\nconst clipboardScript = `\n\u003cscript>\ndocument.addEventListener('click', e => {\n const btn = e.target.closest('button[data-clipboard]');\n if (!btn) return;\n const text = btn.getAttribute('data-clipboard') || '';\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).catch(() => {});\n } else {\n const ta = document.createElement('textarea');\n ta.value = text;\n document.body.appendChild(ta);\n ta.select();\n try { document.execCommand('copy'); } catch {}\n ta.remove();\n }\n const orig = btn.textContent;\n btn.classList.add('copied');\n btn.textContent = 'Copied';\n setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1200);\n});\n\n// Filter chips (people.html)\ndocument.addEventListener('click', e => {\n const chip = e.target.closest('.chip');\n if (!chip) return;\n const group = chip.closest('.filter-group');\n if (!group) return;\n group.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));\n chip.classList.add('active');\n applyFilters();\n});\n\nfunction applyFilters() {\n const grid = document.querySelector('.person-grid');\n if (!grid) return;\n const active = {};\n document.querySelectorAll('.filter-group').forEach(g => {\n const key = g.dataset.filter;\n const chip = g.querySelector('.chip.active');\n active[key] = chip ? chip.dataset.value : '';\n });\n grid.querySelectorAll('.person-card').forEach(card => {\n let show = true;\n for (const k in active) {\n const v = active[k];\n if (!v) continue;\n if ((card.dataset[k] || '') !== v) { show = false; break; }\n }\n card.classList.toggle('hidden', !show);\n });\n}\n\u003c/script>`;\n\n// ----- Person grid + filter chips -----------------------------------------\n\nfunction renderPeopleGrid(personList) {\n if (personList.length === 0) {\n return '\u003cp style=\"color:var(--muted);\">No people found.\u003c/p>';\n }\n return `\u003cdiv class=\"person-grid\">\n${personList.map(p => renderPersonCard(p, p._company)).join('\\n')}\n\u003c/div>`;\n}\n\n// Index page: people grouped by their company, ordered by company ICP score desc.\n// Companies with zero enriched people are skipped here (they still appear on companies.html).\nfunction renderGroupedByCompany(personList) {\n if (personList.length === 0) {\n return '\u003cp style=\"color:var(--muted);\">No people found.\u003c/p>';\n }\n const groups = new Map();\n const unmatched = [];\n for (const p of personList) {\n const c = p._company;\n if (!c) { unmatched.push(p); continue; }\n const key = c.slug || (c.company_name || '').toLowerCase();\n if (!groups.has(key)) groups.set(key, { company: c, people: [] });\n groups.get(key).people.push(p);\n }\n const ordered = [...groups.values()].sort((a, b) =>\n (parseInt(b.company.icp_fit_score) || 0) - (parseInt(a.company.icp_fit_score) || 0)\n );\n\n const sections = ordered.map(({ company, people: members }) => {\n const score = company.icp_fit_score || '?';\n const band = icpBand(score);\n const hasDetail = company.body && company.body.length > 50;\n const nameHtml = hasDetail\n ? `\u003ca href=\"companies/${escapeHtml(company.slug)}.html\">${escapeHtml(company.company_name)}\u003c/a>`\n : escapeHtml(company.company_name);\n const websiteHtml = company.website\n ? ` · \u003ca href=\"${escapeHtml(company.website)}\" target=\"_blank\" rel=\"noopener\">${escapeHtml(company.website.replace(/^https?:\\/\\/(www\\.)?/, ''))}\u003c/a>`\n : '';\n const metaBits = [\n `${members.length} speaker${members.length === 1 ? '' : 's'}`,\n company.industry ? escapeHtml(company.industry) : null,\n ].filter(Boolean).join(' · ');\n return `\u003csection class=\"company-group\" data-icpband=\"${band}\">\n \u003cheader class=\"company-header\">\n \u003cdiv class=\"company-header-row\">\n \u003ch2>${nameHtml}\u003c/h2>\n \u003cspan class=\"icp-badge icp-${band}\">ICP ${escapeHtml(String(score))}\u003c/span>\n \u003c/div>\n \u003cp class=\"company-meta\">${metaBits}${websiteHtml}\u003c/p>\n ${company.icp_fit_reasoning ? `\u003cp class=\"company-fit\">${escapeHtml(company.icp_fit_reasoning)}\u003c/p>` : ''}\n \u003c/header>\n \u003cdiv class=\"company-people\">\n ${members.map(p => renderPersonCard(p, company)).join('\\n')}\n \u003c/div>\n \u003c/section>`;\n });\n\n if (unmatched.length) {\n sections.push(`\u003csection class=\"company-group\" data-icpband=\"low\">\n \u003cheader class=\"company-header\">\n \u003cdiv class=\"company-header-row\">\u003ch2>Unmatched\u003c/h2>\u003c/div>\n \u003cp class=\"company-meta\">${unmatched.length} speaker${unmatched.length === 1 ? '' : 's'} without a resolved company file\u003c/p>\n \u003c/header>\n \u003cdiv class=\"company-people\">\n ${unmatched.map(p => renderPersonCard(p, null)).join('\\n')}\n \u003c/div>\n \u003c/section>`);\n }\n\n return `\u003cdiv class=\"company-groups\">\\n${sections.join('\\n')}\\n\u003c/div>`;\n}\n\nfunction uniqValues(list, fn) {\n return [...new Set(list.map(fn).filter(Boolean))].sort();\n}\n\n// people.html filter chips: ICP band, role bucket, company.\n// Activating a chip applies a single-value filter against the matching\n// data-* attribute on each .person-card. Click handlers are in clipboardScript.\nfunction renderFilterBar(personList) {\n const compNames = uniqValues(personList, p => p.company);\n const roles = uniqValues(personList, p => roleBucket(p.title));\n const bands = ['high', 'mid', 'low'];\n\n const chip = (val, label) => `\u003cspan class=\"chip${val === '' ? ' active' : ''}\" data-value=\"${escapeHtml(val)}\">${escapeHtml(label)}\u003c/span>`;\n\n const bandLabels = { high: 'High (8-10)', mid: 'Mid (5-7)', low: 'Low (1-4)' };\n\n return `\u003cdiv class=\"filter-bar\">\n \u003cdiv class=\"filter-group\" data-filter=\"icpband\">\n \u003cspan class=\"label\">ICP\u003c/span>\n ${chip('', 'All')}\n ${bands.map(b => chip(b, bandLabels[b])).join(' ')}\n \u003c/div>\n \u003cdiv class=\"filter-group\" data-filter=\"role\">\n \u003cspan class=\"label\">Role\u003c/span>\n ${chip('', 'All')}\n ${roles.map(r => chip(r, r)).join(' ')}\n \u003c/div>\n \u003cdiv class=\"filter-group\" data-filter=\"company\">\n \u003cspan class=\"label\">Company\u003c/span>\n ${chip('', 'All')}\n ${compNames.map(c => chip(c.toLowerCase(), c)).join(' ')}\n \u003c/div>\n \u003c/div>`;\n}\n\n// ----- Companies table with attendees expandable ---------------------------\n\nfunction renderCompaniesTable() {\n // Group people by company slug or name (lowered) so each row can show its attendees.\n const byCompany = new Map();\n for (const p of people) {\n const key = p._company ? (p._company.slug || (p._company.company_name || '').toLowerCase()) : null;\n if (!key) continue;\n if (!byCompany.has(key)) byCompany.set(key, []);\n byCompany.get(key).push(p);\n }\n\n return deduped.map(c => {\n const sc = scoreClass(c.icp_fit_score);\n const hasDetail = c.body && c.body.length > 50;\n const nameHtml = hasDetail\n ? `\u003ca href=\"companies/${c.slug}.html\">${escapeHtml(c.company_name)}\u003c/a>`\n : escapeHtml(c.company_name);\n const websiteHtml = c.website\n ? `\u003cbr>\u003ca href=\"${escapeHtml(c.website)}\" target=\"_blank\" style=\"font-size:0.75rem;color:var(--muted);\">${escapeHtml(c.website.replace(/^https?:\\/\\/(www\\.)?/, ''))}\u003c/a>`\n : '';\n const key = c.slug || (c.company_name || '').toLowerCase();\n const attendees = byCompany.get(key) || [];\n const attendeeBlock = attendees.length ? `\n \u003cdetails class=\"attendees\">\n \u003csummary>${attendees.length} attendee${attendees.length === 1 ? '' : 's'}\u003c/summary>\n \u003cul>${attendees.map(a => `\u003cli>\u003cstrong>${escapeHtml(a.name || a.slug)}\u003c/strong>${a.title ? ' — ' + escapeHtml(a.title) : ''}${(a.links && a.links.linkedin) ? ` · \u003ca href=\"${escapeHtml(a.links.linkedin)}\" target=\"_blank\" rel=\"noopener\">LinkedIn\u003c/a>` : ''}\u003c/li>`).join('')}\u003c/ul>\n \u003c/details>` : '';\n return ` \u003ctr>\n \u003ctd>\u003cspan class=\"score ${sc}\">${escapeHtml(c.icp_fit_score || '—')}\u003c/span>\u003c/td>\n \u003ctd>${nameHtml}${websiteHtml}${attendeeBlock}\u003c/td>\n \u003ctd style=\"max-width:200px;\">${escapeHtml(c.product_description || '')}\u003c/td>\n \u003ctd>${escapeHtml(c.industry || '')}\u003c/td>\n \u003ctd class=\"reasoning\">${escapeHtml(c.icp_fit_reasoning || '')}\u003c/td>\n \u003c/tr>`;\n }).join('\\n');\n}\n\n// ----- Compose final pages -------------------------------------------------\n\nconst escapedTitle = escapeHtml(title);\nconst metaLine = `${people.length} speakers · ${deduped.length} companies · ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`;\n\nconst navHtml = (active) => `\u003cdiv class=\"nav-bar\">\n \u003ca href=\"index.html\" class=\"${active === 'index' ? 'active' : ''}\">People\u003c/a>\n \u003ca href=\"people.html\" class=\"${active === 'people' ? 'active' : ''}\">People (filterable)\u003c/a>\n \u003ca href=\"companies.html\" class=\"${active === 'companies' ? 'active' : ''}\">Companies\u003c/a>\n\u003c/div>`;\n\nfunction injectCss(html) {\n return html.replace('\u003c/style>', `${eventCss}\\n\u003c/style>`);\n}\n\nfunction injectScript(html) {\n return html.replace('\u003c/body>', `${clipboardScript}\\n\u003c/body>`);\n}\n\nfunction renderShell(activeNav, contentHtml, pageTitle) {\n let html = template\n .replace(/\\{\\{TITLE\\}\\}/g, escapeHtml(pageTitle))\n .replace(/\\{\\{COMPANY_NAME\\}\\}/g, escapedTitle)\n .replace(/\\{\\{META\\}\\}/g, metaLine)\n .replace(/\\{\\{TOTAL\\}\\}/g, String(total))\n .replace(/\\{\\{HIGH_COUNT\\}\\}/g, String(high))\n .replace(/\\{\\{MEDIUM_COUNT\\}\\}/g, String(medium))\n .replace(/\\{\\{LOW_COUNT\\}\\}/g, String(low))\n .replace(/\\{\\{HIGH_PCT\\}\\}/g, String(highPct))\n .replace(/\\{\\{MEDIUM_PCT\\}\\}/g, String(mediumPct))\n .replace(/\\{\\{LOW_PCT\\}\\}/g, String(lowPct))\n .replace(/\\{\\{TABLE_ROWS\\}\\}/g, () => '');\n\n // Replace the entire \u003ctable>...\u003c/table> block with our content. Use a\n // function replacer so any ` event-prospecting — Skillopedia characters inside contentHtml (e.g. price\n // strings, regex examples in person hooks) aren't interpreted as `{head-tags}` /\n // `$1` / `$` replacement patterns.\n html = html.replace(/\u003ctable class=\"results-table\">[\\s\\S]*?\u003c\\/table>/, () => `\u003cdiv class=\"page-content\">${navHtml(activeNav)}\\n${contentHtml}\u003c/div>`);\n\n html = injectCss(html);\n html = injectScript(html);\n return html;\n}\n\nconst indexHtml = renderShell('index', renderGroupedByCompany(people), `Event Prospecting — ${title}`);\nwriteFileSync(join(dir, 'index.html'), indexHtml);\n\nconst peopleHtml = renderShell(\n 'people',\n `${renderFilterBar(people)}\\n${renderPeopleGrid(people)}`,\n `People — ${title}`\n);\nwriteFileSync(join(dir, 'people.html'), peopleHtml);\n\nconst companiesContent = `\u003ctable class=\"results-table\">\n \u003cthead>\n \u003ctr>\n \u003cth>Score\u003c/th>\n \u003cth>Company\u003c/th>\n \u003cth>Product\u003c/th>\n \u003cth>Industry\u003c/th>\n \u003cth>Fit Reasoning\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n${renderCompaniesTable()}\n \u003c/tbody>\n \u003c/table>`;\nconst companiesHtml = renderShell('companies', companiesContent, `Companies — ${title}`);\nwriteFileSync(join(dir, 'companies.html'), companiesHtml);\n\n// ----- Per-company detail pages -------------------------------------------\n\ntry { mkdirSync(join(dir, 'companies'), { recursive: true }); } catch {}\n\nfor (const c of deduped) {\n if (!c.body || c.body.length \u003c 50) continue;\n const sc = scoreClass(c.icp_fit_score);\n const bodyHtml = mdToHtml(c.body);\n\n const companyHtml = `\u003c!DOCTYPE html>\n\u003chtml lang=\"en\">\n\u003chead>\n\u003cmeta charset=\"UTF-8\">\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\u003ctitle>${escapeHtml(c.company_name)} — Research\u003c/title>\n\u003clink href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n\u003cstyle>\n :root { --brand:#F03603; --high:#90C94D; --medium:#F4BA41; --low:#F03603; --black:#100D0D; --gray:#514F4F; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }\n * { margin:0; padding:0; box-sizing:border-box; }\n body { font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.6; font-size:16px; }\n .container { max-width:800px; margin:0 auto; padding:2rem 1.5rem; }\n a { color:var(--brand); text-decoration:none; }\n a:hover { text-decoration:underline; }\n .back { font-size:0.875rem; color:var(--muted); margin-bottom:1.5rem; display:inline-block; }\n .back:hover { color:var(--brand); }\n header { margin-bottom:2rem; }\n header h1 { font-size:1.5rem; font-weight:600; margin-bottom:0.25rem; }\n header .meta { color:var(--muted); font-size:0.875rem; }\n .score-badge { display:inline-block; font-size:0.875rem; font-weight:700; padding:4px 14px; border-radius:4px; margin-right:0.75rem; }\n .score-badge.high { background:rgba(144,201,77,0.12); color:#5a8a1a; border:1px solid rgba(144,201,77,0.3); }\n .score-badge.medium { background:rgba(244,186,65,0.12); color:#9a7520; border:1px solid rgba(244,186,65,0.3); }\n .score-badge.low { background:rgba(240,54,3,0.08); color:var(--low); border:1px solid rgba(240,54,3,0.2); }\n .fields { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:1.25rem; margin-bottom:2rem; display:grid; grid-template-columns:auto 1fr; gap:0.375rem 1rem; font-size:0.875rem; }\n .fields dt { color:var(--muted); font-weight:500; }\n .fields dd { color:var(--text); }\n .research { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:1.5rem; }\n .research h2 { font-size:1.125rem; font-weight:600; margin:1.5rem 0 0.5rem 0; color:var(--black); }\n .research h2:first-child { margin-top:0; }\n .research p { margin-bottom:0.75rem; }\n .research ul { margin:0.5rem 0 1rem 1.25rem; }\n .research li { margin-bottom:0.375rem; font-size:0.875rem; }\n .confidence { font-size:0.75rem; font-weight:600; padding:1px 6px; border-radius:2px; }\n .confidence.high { background:rgba(144,201,77,0.12); color:#5a8a1a; }\n .confidence.medium { background:rgba(244,186,65,0.12); color:#9a7520; }\n .confidence.low { background:rgba(240,54,3,0.08); color:var(--low); }\n footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid var(--border); text-align:center; font-size:0.75rem; color:var(--muted); }\n\u003c/style>\n\u003c/head>\n\u003cbody>\n\u003cdiv class=\"container\">\n \u003ca href=\"../index.html\" class=\"back\">← Back to overview\u003c/a>\n \u003cheader>\n \u003ch1>${escapeHtml(c.company_name)}\u003c/h1>\n \u003cdiv class=\"meta\">\n \u003cspan class=\"score-badge ${sc}\">ICP Score: ${escapeHtml(c.icp_fit_score || '—')}\u003c/span>\n ${c.website ? `\u003ca href=\"${escapeHtml(c.website)}\" target=\"_blank\">${escapeHtml(c.website)}\u003c/a>` : ''}\n \u003c/div>\n \u003c/header>\n \u003cdl class=\"fields\">\n ${c.product_description ? `\u003cdt>Product\u003c/dt>\u003cdd>${escapeHtml(c.product_description)}\u003c/dd>` : ''}\n ${c.industry ? `\u003cdt>Industry\u003c/dt>\u003cdd>${escapeHtml(c.industry)}\u003c/dd>` : ''}\n ${c.target_audience ? `\u003cdt>Target Audience\u003c/dt>\u003cdd>${escapeHtml(c.target_audience)}\u003c/dd>` : ''}\n ${c.key_features ? `\u003cdt>Key Features\u003c/dt>\u003cdd>${escapeHtml(c.key_features)}\u003c/dd>` : ''}\n ${c.employee_estimate ? `\u003cdt>Employees\u003c/dt>\u003cdd>${escapeHtml(c.employee_estimate)}\u003c/dd>` : ''}\n ${c.funding_info ? `\u003cdt>Funding\u003c/dt>\u003cdd>${escapeHtml(c.funding_info)}\u003c/dd>` : ''}\n ${c.headquarters ? `\u003cdt>HQ\u003c/dt>\u003cdd>${escapeHtml(c.headquarters)}\u003c/dd>` : ''}\n ${c.icp_fit_reasoning ? `\u003cdt>Fit Reasoning\u003c/dt>\u003cdd>${escapeHtml(c.icp_fit_reasoning)}\u003c/dd>` : ''}\n \u003c/dl>\n \u003cdiv class=\"research\">\n ${bodyHtml}\n \u003c/div>\n\u003c/div>\n\u003cfooter>Generated by \u003ca href=\"https://github.com/anthropics/skills\">event-prospecting\u003c/a> · Powered by \u003ca href=\"https://browserbase.com\">Browserbase\u003c/a>\u003c/footer>\n\u003c/body>\n\u003c/html>`;\n\n writeFileSync(join(dir, 'companies', `${c.slug}.html`), companyHtml);\n}\n\n// ----- CSV ----------------------------------------------------------------\n\n// One row per speaker — this is the spreadsheet the AE imports into outbound\n// tooling, so it has to answer \"who do I reach out to\", not \"what companies\n// attended\". Company-level fields (ICP score, fit reasoning, website, industry)\n// are joined in from the resolved company file.\nconst personCols = [\n 'name', 'title', 'company', 'icp_fit_score',\n 'linkedin', 'x', 'github', 'blog', 'podcast',\n 'hook', 'role_reason', 'dm_opener',\n 'icp_fit_reasoning', 'company_website', 'company_industry',\n 'person_slug', 'company_slug', 'image',\n];\n\nfunction personRow(p) {\n const c = p._company || {};\n const links = (p.links && typeof p.links === 'object') ? p.links : {\n linkedin: p.linkedin || null,\n x: p.x || p.twitter || null,\n github: p.github || null,\n blog: p.blog || null,\n podcast: p.podcast || null,\n };\n const score = c.icp_fit_score || p.icp_fit_score || '';\n return {\n name: p.name || '',\n title: p.title || '',\n company: p.company || c.company_name || '',\n icp_fit_score: score,\n linkedin: links.linkedin || '',\n x: links.x || '',\n github: links.github || '',\n blog: links.blog || '',\n podcast: links.podcast || '',\n hook: p.hook || extractSection(p.body, 'Hook') || '',\n role_reason: p.role_reason || extractSection(p.body, 'Why the person') || '',\n dm_opener: p.dm_opener || extractSection(p.body, 'DM Opener') || '',\n icp_fit_reasoning: c.icp_fit_reasoning || '',\n company_website: c.website || '',\n company_industry: c.industry || '',\n person_slug: p.slug || '',\n company_slug: c.slug || '',\n image: p.image || '',\n };\n}\n\nfunction csvEscape(v) {\n if (v == null) return '';\n const s = typeof v === 'object' ? JSON.stringify(v) : String(v);\n // Quote on any RFC 4180 record-/field-terminator: comma, quote, LF, or bare CR.\n if (s.includes(',') || s.includes('\"') || s.includes('\\n') || s.includes('\\r')) return '\"' + s.replace(/\"/g, '\"\"') + '\"';\n return s;\n}\n\nconst csvLines = [personCols.join(',')];\nfor (const p of people) {\n const row = personRow(p);\n csvLines.push(personCols.map(c => csvEscape(row[c])).join(','));\n}\nwriteFileSync(join(dir, 'results.csv'), csvLines.join('\\n') + '\\n');\n\n// ----- Summary ------------------------------------------------------------\n\nconsole.error(JSON.stringify({\n total_companies: deduped.length,\n total_people: people.length,\n high_fit: high,\n medium_fit: medium,\n low_fit: low,\n files_generated: {\n index: join(dir, 'index.html'),\n people: join(dir, 'people.html'),\n companies: join(dir, 'companies.html'),\n company_pages: deduped.filter(c => c.body && c.body.length > 50).length,\n csv: join(dir, 'results.csv')\n }\n}, null, 2));\n\nconsole.log(join(dir, 'index.html'));\n\nif (shouldOpen) {\n const { execSync } = await import('child_process');\n try { execSync(`open \"${join(dir, 'index.html')}\"`); } catch {}\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":40145,"content_sha256":"2f19edc05b574773bddd791b37238e212f9fb9242f2c6a7565352e2dc8a9ebac"},{"filename":"scripts/extract_event.mjs","content":"#!/usr/bin/env node\n// extract_event.mjs — read recon.json, dispatch to platform-specific extractor,\n// write people.jsonl (one speaker per line) and seed_companies.txt.\n//\n// Usage: node extract_event.mjs \u003coutput-dir>\n\nimport { execFileSync } from 'child_process';\nimport { readFileSync, writeFileSync } from 'fs';\nimport { join } from 'path';\n\nconst outDir = process.argv[2];\nif (!outDir) { console.error('Usage: extract_event.mjs \u003coutput-dir>'); process.exit(1); }\n\nconst recon = JSON.parse(readFileSync(join(outDir, 'recon.json'), 'utf-8'));\n\nfunction browse(...subargs) {\n return execFileSync('browse', subargs, {\n encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024, timeout: 60000,\n });\n}\n\nfunction slugify(s) {\n return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');\n}\n\nfunction extractFromNextData(paths) {\n // Build a JS expression that walks __NEXT_DATA__ for each path and unions the arrays.\n const js = `(() => {\n const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent);\n function get(obj, path) {\n // path like '.props.pageProps.foo[0].bar' — naive parser, sufficient\n const tokens = path.match(/\\\\.[a-zA-Z_$][\\\\w$]*|\\\\[\\\\d+\\\\]/g) || [];\n let cur = obj;\n for (const t of tokens) {\n if (!cur) return null;\n if (t.startsWith('.')) cur = cur[t.slice(1)];\n else cur = cur[parseInt(t.slice(1, -1), 10)];\n }\n return cur;\n }\n function pickImage(s) {\n // Detect image fields by KEY NAME regex (across Next.js / Sanity / Sessionize / custom CMS shapes).\n // Matches anything containing portrait/headshot/photo/image/picture/avatar/thumbnail (case-insensitive).\n // Prefer color over monochrome by sorting matched keys (color* \u003c mono*).\n // Object values get unwrapped via .url, .src, .asset.url, or fields.file.url.\n const re = /portrait|headshot|photo|image|picture|avatar|thumbnail/i;\n const keys = Object.keys(s).filter(k => re.test(k));\n // Push monochrome/grayscale variants to the end so colour wins.\n keys.sort((a, b) => {\n const aMono = /mono|grey|gray|black/i.test(a) ? 1 : 0;\n const bMono = /mono|grey|gray|black/i.test(b) ? 1 : 0;\n return aMono - bMono;\n });\n // Unwrap a single image value (string, object, or array of either —\n // Sanity / custom CMSes sometimes use plural keys like \\`images: [{url}]\\`).\n function unwrap(v) {\n if (!v) return null;\n if (typeof v === 'string') return v;\n if (Array.isArray(v)) {\n for (const item of v) {\n const got = unwrap(item);\n if (got) return got;\n }\n return null;\n }\n if (typeof v === 'object') {\n if (typeof v.url === 'string') return v.url;\n if (typeof v.src === 'string') return v.src;\n if (v.asset && typeof v.asset.url === 'string') return v.asset.url;\n if (v.fields && v.fields.file && typeof v.fields.file.url === 'string') return v.fields.file.url;\n }\n return null;\n }\n for (const k of keys) {\n const got = unwrap(s[k]);\n if (got) return got;\n }\n return null;\n }\n const all = [];\n ${JSON.stringify(paths)}.forEach(p => {\n const arr = get(data, p);\n if (Array.isArray(arr)) all.push(...arr);\n });\n return all.map(s => ({\n name: s.name || s.fullName || null,\n title: s.title || s.role || null,\n company: s.companyName || s.company || s.org || null,\n linkedin: s.linkedInProfile || s.linkedinUrl || s.linkedin || null,\n bio: s.bio || s.description || null,\n image: pickImage(s),\n }));\n })()`;\n // Don't JSON.parse open's output — `browse open` can emit non-JSON banners,\n // and we don't use the return value. The matching `extractFromMarkdown` path\n // ignores it the same way.\n browse('open', recon.url);\n browse('wait', 'timeout', '2000');\n const evalRes = JSON.parse(browse('eval', js));\n return evalRes.result || [];\n}\n\nfunction extractFromMarkdown() {\n browse('open', recon.url);\n browse('wait', 'timeout', '2500');\n const md = JSON.parse(browse('get', 'markdown')).markdown || '';\n // Naive: find blocks of \"#### {Name}\\n\\n{Role}\\n\\n{Company}\\n\\n[LinkedIn]({url})\"\n // This is a fallback — coverage is best-effort.\n const blocks = md.split(/\\n#{2,4} /);\n const out = [];\n for (const b of blocks) {\n const lines = b.split(/\\n+/).map(l => l.trim()).filter(Boolean);\n if (lines.length \u003c 2) continue;\n const name = lines[0];\n if (!/^[A-Z]/.test(name)) continue;\n const linkedinMatch = b.match(/linkedin\\.com\\/in\\/([\\w-]+)/i);\n out.push({\n name,\n title: lines[1] || null,\n company: lines[2] || null,\n linkedin: linkedinMatch ? `https://www.linkedin.com/in/${linkedinMatch[1]}/` : null,\n });\n }\n return out;\n}\n\nlet people = [];\nif (recon.strategy === 'next-data-eval') {\n people = extractFromNextData(recon.nextDataPaths || []);\n} else if (recon.strategy === 'markdown') {\n people = extractFromMarkdown();\n} else {\n console.error(`Strategy ${recon.strategy} not implemented in v0.1; falling back to markdown.`);\n people = extractFromMarkdown();\n}\n\n// Resolve relative image URLs against the event origin (some Next sites store\n// `/images/speakers/foo.jpg` instead of an absolute CDN URL).\nconst eventOrigin = (() => { try { return new URL(recon.url).origin; } catch { return null; } })();\nfunction resolveImage(src) {\n if (!src) return null;\n if (/^https?:\\/\\//i.test(src)) return src;\n if (src.startsWith('//')) return 'https:' + src;\n if (src.startsWith('/') && eventOrigin) return eventOrigin + src;\n return src;\n}\n\n// Add a slug + normalize image URL. Disambiguate duplicate names with -2, -3,\n// etc. so a later subagent's people/{slug}.md write doesn't silently overwrite\n// a previous speaker who happens to share a name.\nconst slugCounts = new Map();\npeople = people.map(p => {\n const base = slugify(p.name);\n const n = (slugCounts.get(base) || 0) + 1;\n slugCounts.set(base, n);\n const slug = n === 1 ? base : `${base}-${n}`;\n return { ...p, image: resolveImage(p.image), slug };\n});\n\n// Filter event-host employees and obvious noise. The host org is derived from\n// the event URL — for `stripesessions.com` that's stripe; for subdomain-hosted\n// events like `events.stripe.com` we want stripe (not \"events\"). We take the\n// registrable-domain chunk (parts[-2]) and try to strip a single event-platform\n// suffix. Guarded by a min-length check on the result so `devconf.io` doesn't\n// collapse to `dev` (and silently filter speakers from DEV / dev.to), and\n// `aiconf.io` doesn't collapse to `ai`. Falls down on .co.uk-style public\n// suffixes; v0.1 accepts that.\nconst hostOrg = (() => {\n try {\n const h = new URL(recon.url).hostname.replace(/^www\\./, '').toLowerCase();\n const parts = h.split('.');\n const sld = parts.length >= 2 ? parts[parts.length - 2] : parts[0];\n const stripped = sld.replace(/(?:sessions?|conf(?:erence)?|summit|events?)$/, '');\n return (stripped !== sld && stripped.length >= 5) ? stripped : sld;\n } catch { return null; }\n})();\n\nconst filterArgs = process.argv.slice(2);\nconst userCompanyArg = (() => {\n const i = filterArgs.indexOf('--user-company');\n return i !== -1 ? filterArgs[i + 1] : null;\n})();\n\n// Compare via slugify on both sides so a profile slug like \"acme-corp\" matches a\n// speaker's `company` of \"Acme Corp\" (which lower-cases to \"acme corp\" and would\n// otherwise miss).\nconst dropList = new Set([\n hostOrg && slugify(hostOrg),\n userCompanyArg && slugify(userCompanyArg),\n].filter(Boolean));\n\nconst filtered = people.filter(p => {\n if (!p.company) return true; // keep \"unknown company\" — synth assigns later\n return !dropList.has(slugify(p.company));\n});\n\nconsole.error(`Filtered ${people.length - filtered.length} host-org / user-company employees`);\npeople = filtered;\n\n// Write people.jsonl\nconst peopleFile = join(outDir, 'people.jsonl');\nwriteFileSync(peopleFile, people.map(p => JSON.stringify(p)).join('\\n') + '\\n');\n\n// Roll up to unique companies\nconst companies = [...new Set(people.map(p => p.company).filter(Boolean))].sort();\nwriteFileSync(join(outDir, 'seed_companies.txt'), companies.join('\\n') + '\\n');\n\nconsole.error(`Extracted ${people.length} people, ${companies.length} unique companies → ${outDir}`);\nconsole.log(JSON.stringify({ peopleCount: people.length, companyCount: companies.length, peopleFile }, null, 2));\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":8490,"content_sha256":"3f08317135a7b0c8c701e7dec0a31ac347ff776e07f66f575110efec88e5b31b"},{"filename":"scripts/extract_page.mjs","content":"#!/usr/bin/env node\n// Extract structured page content for company research.\n// Fetches via `browse cloud fetch` (raw HTML to a temp file), pulls title + meta tags\n// + visible body text, and auto-falls back to `browse get markdown` when content is thin.\n//\n// Usage: node extract_page.mjs \u003curl> [--max-chars N]\n// Output (stdout): structured block consumable by a research subagent.\n\nimport { execFileSync } from \"node:child_process\";\nimport { mkdtempSync, readFileSync, rmSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\nconst THIN_CONTENT_THRESHOLD = 200; // body chars under this → JS-rendered, fall back\n\nfunction parseArgs(argv) {\n const args = { url: null, maxChars: 3000 };\n for (let i = 0; i \u003c argv.length; i++) {\n const a = argv[i];\n if (a === \"--max-chars\") args.maxChars = parseInt(argv[++i], 10);\n else if (!args.url) args.url = a;\n }\n if (!args.url) {\n console.error(\"Usage: extract_page.mjs \u003curl> [--max-chars N]\");\n process.exit(2);\n }\n return args;\n}\n\nfunction browseFetch(url, outFile) {\n execFileSync(\"browse\", [\"cloud\", \"fetch\", \"--allow-redirects\", url, \"--output\", outFile], {\n stdio: [\"ignore\", \"ignore\", \"ignore\"],\n });\n}\n\nfunction browseGetMarkdown(url) {\n const session = `extract-page-${process.pid}-${Date.now()}`;\n const env = { ...process.env, BROWSE_SESSION: session };\n try {\n execFileSync(\"browse\", [\"open\", url, \"--local\", \"--headless\"], {\n stdio: [\"ignore\", \"ignore\", \"ignore\"],\n timeout: 90000,\n env,\n });\n const out = execFileSync(\"browse\", [\"get\", \"markdown\"], {\n encoding: \"utf8\",\n timeout: 90000,\n maxBuffer: 50 * 1024 * 1024,\n env,\n });\n // browse prints banners (e.g. \"Update available...\") before the JSON blob.\n // Find the first '{' and try to JSON.parse from there.\n const start = out.indexOf(\"{\");\n if (start \u003c 0) return \"\";\n try {\n const parsed = JSON.parse(out.slice(start));\n if (parsed && typeof parsed.markdown === \"string\") return parsed.markdown;\n } catch {\n // Fallback: extract \"markdown\": \"...\" with a lenient regex that handles\n // escaped quotes and newlines.\n const m = out.slice(start).match(/\"markdown\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"/s);\n if (m) {\n try { return JSON.parse(`\"${m[1]}\"`); } catch { return m[1]; }\n }\n }\n return \"\";\n } catch (err) {\n return \"\";\n } finally {\n try {\n execFileSync(\"browse\", [\"stop\"], {\n stdio: [\"ignore\", \"ignore\", \"ignore\"],\n timeout: 15000,\n env,\n });\n } catch {}\n }\n}\n\nfunction extractMeta(html, name, attr = \"name\") {\n const re = new RegExp(\n `\u003cmeta\\\\s+${attr}=[\"']${name}[\"']\\\\s+content=[\"']([^\"']*)[\"']`,\n \"i\"\n );\n const re2 = new RegExp(\n `\u003cmeta\\\\s+content=[\"']([^\"']*)[\"']\\\\s+${attr}=[\"']${name}[\"']`,\n \"i\"\n );\n const m = html.match(re) || html.match(re2);\n return m ? m[1].trim() : \"\";\n}\n\nfunction extractTitle(html) {\n const m = html.match(/\u003ctitle[^>]*>([^\u003c]*)\u003c\\/title>/i);\n return m ? m[1].trim() : \"\";\n}\n\nfunction extractVisibleText(html, maxChars) {\n // Multi-line aware script/style removal.\n let s = html\n .replace(/\u003cscript\\b[^>]*>[\\s\\S]*?\u003c\\/script>/gi, \" \")\n .replace(/\u003cstyle\\b[^>]*>[\\s\\S]*?\u003c\\/style>/gi, \" \")\n .replace(/\u003cnoscript\\b[^>]*>[\\s\\S]*?\u003c\\/noscript>/gi, \" \")\n .replace(/\u003c!--[\\s\\S]*?-->/g, \" \")\n .replace(/\u003c[^>]+>/g, \" \")\n .replace(/ /g, \" \")\n .replace(/&/g, \"&\")\n .replace(/</g, \"\u003c\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/&#[0-9]+;/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n return s.slice(0, maxChars);\n}\n\nfunction extractHeadings(html, limit = 10) {\n const re = /\u003ch[1-3][^>]*>([\\s\\S]*?)\u003c\\/h[1-3]>/gi;\n const out = [];\n let m;\n while ((m = re.exec(html)) && out.length \u003c limit) {\n const text = m[1].replace(/\u003c[^>]+>/g, \"\").replace(/\\s+/g, \" \").trim();\n if (text) out.push(text);\n }\n return out;\n}\n\nfunction main() {\n const { url, maxChars } = parseArgs(process.argv.slice(2));\n const dir = mkdtempSync(join(tmpdir(), \"extract_page_\"));\n const htmlFile = join(dir, \"page.html\");\n\n let html = \"\";\n let fetchOk = false;\n try {\n browseFetch(url, htmlFile);\n html = readFileSync(htmlFile, \"utf8\");\n fetchOk = true;\n } catch (err) {\n console.error(`[extract_page] browse cloud fetch failed: ${err.message}`);\n }\n\n const title = extractTitle(html);\n const metaDesc = extractMeta(html, \"description\");\n const ogTitle = extractMeta(html, \"og:title\", \"property\");\n const ogDesc = extractMeta(html, \"og:description\", \"property\");\n const headings = extractHeadings(html);\n let body = extractVisibleText(html, maxChars);\n\n // Thin content → JS-rendered SPA → fall back to browse get markdown.\n let fallbackUsed = false;\n if (body.length \u003c THIN_CONTENT_THRESHOLD) {\n const md = browseGetMarkdown(url);\n if (md && md.length > body.length) {\n body = md.replace(/\\s+/g, \" \").slice(0, maxChars);\n fallbackUsed = true;\n }\n }\n\n rmSync(dir, { recursive: true, force: true });\n\n // Structured output for subagent to read.\n const lines = [\n `URL: ${url}`,\n `FETCH_OK: ${fetchOk}`,\n `FALLBACK_TO_BROWSE: ${fallbackUsed}`,\n `TITLE: ${title}`,\n `META_DESCRIPTION: ${metaDesc}`,\n `OG_TITLE: ${ogTitle}`,\n `OG_DESCRIPTION: ${ogDesc}`,\n `HEADINGS: ${headings.join(\" | \")}`,\n `BODY_CHARS: ${body.length}`,\n `BODY:`,\n body,\n ];\n process.stdout.write(lines.join(\"\\n\") + \"\\n\");\n}\n\nmain();\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":5525,"content_sha256":"dcd06ddd53249bef987e05e5c2110ed2ffbc34a969dffb6c99241c3b2c7e72ef"},{"filename":"scripts/package.json","content":"{\n \"name\": \"event-prospecting-scripts\",\n \"version\": \"0.1.0\",\n \"type\": \"module\",\n \"private\": true\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":103,"content_sha256":"a70b03491d60399784ce2053b4902b0ff88ccf374669de770d501e4af8ae79a1"},{"filename":"scripts/recon.mjs","content":"#!/usr/bin/env node\n// recon.mjs — probe an event URL, identify the platform, persist a recon.json\n// describing how to extract people. Output dir is the second arg or stdout.\n//\n// Usage: node recon.mjs \u003cevent-url> [output-dir]\n//\n// Detection priority:\n// 1. Next.js __NEXT_DATA__ (custom Next sites — Stripe Sessions class)\n// 2. Sessionize generator meta or sessionz.io script\n// 3. Lu.ma og:site_name\n// 4. Eventbrite og:site_name\n// 5. JSON-LD Event block\n// 6. Fall through to markdown-extraction strategy\n\nimport { execFileSync } from 'child_process';\nimport { writeFileSync, mkdirSync } from 'fs';\nimport { join } from 'path';\n\nconst args = process.argv.slice(2);\nif (args.length \u003c 1 || args.includes('--help')) {\n console.error(`Usage: node recon.mjs \u003cevent-url> [output-dir]`);\n process.exit(1);\n}\n\nconst url = args[0];\nconst outDir = args[1];\n\nfunction browse(...subargs) {\n return execFileSync('browse', subargs, {\n encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024, timeout: 60000,\n });\n}\n\nfunction probe() {\n // Navigate + settle\n browse('open', url);\n browse('wait', 'timeout', '2500');\n const titleRes = JSON.parse(browse('get', 'title'));\n const title = titleRes.title || '';\n\n // Probe in priority order via a single eval — cheaper than N calls.\n const probeJs = `(() => {\n const nd = document.getElementById('__NEXT_DATA__');\n const meta = document.querySelector('meta[name=\"generator\"]');\n const og = document.querySelector('meta[property=\"og:site_name\"]');\n const jsonLd = [...document.querySelectorAll('script[type=\"application/ld+json\"]')]\n .map(s => { try { return JSON.parse(s.textContent); } catch { return null; }})\n .filter(Boolean);\n return {\n hasNextData: !!nd,\n nextDataLen: nd ? nd.textContent.length : 0,\n generator: meta ? meta.content : null,\n ogSite: og ? og.content : null,\n jsonLdEvents: jsonLd.filter(j => j['@type'] === 'Event').length,\n hostname: location.hostname\n };\n })()`;\n const evalRes = JSON.parse(browse('eval', probeJs));\n const r = evalRes.result || {};\n\n // Decide platform\n let platform = 'custom';\n let strategy = 'markdown';\n let nextDataPaths = null;\n\n if (r.hasNextData) {\n platform = 'next-data';\n strategy = 'next-data-eval';\n // Find arrays of speaker-like objects inside __NEXT_DATA__\n const findJs = `(() => {\n const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent);\n const out = [];\n function walk(o, path='') {\n if (Array.isArray(o)) {\n if (o.length > 3 && typeof o[0] === 'object' && o[0] !== null) {\n const keys = Object.keys(o[0]);\n const hasName = keys.some(k => /name/i.test(k));\n const hasLinkedIn = JSON.stringify(o[0]).match(/linkedin/i);\n if (hasName && hasLinkedIn) out.push({ path, len: o.length, keys: keys.slice(0,12) });\n }\n o.forEach((v,i) => walk(v, path+'['+i+']'));\n } else if (o && typeof o === 'object') {\n Object.keys(o).forEach(k => walk(o[k], path+'.'+k));\n }\n }\n walk(data);\n // Keep only top-level (non-nested) speaker arrays — drop talks[N].speakers\n return out.filter(x => !/\\\\.talks\\\\[\\\\d+\\\\]\\\\.speakers/.test(x.path)).slice(0, 5);\n })()`;\n const findRes = JSON.parse(browse('eval', findJs));\n nextDataPaths = (findRes.result || []).map(x => x.path);\n } else if (r.generator && /sessionize/i.test(r.generator)) {\n platform = 'sessionize';\n strategy = 'sessionize-api';\n } else if (r.hostname && /lu\\.ma/.test(r.hostname)) {\n platform = 'luma';\n strategy = 'json-ld';\n } else if (r.ogSite && /eventbrite/i.test(r.ogSite)) {\n platform = 'eventbrite';\n strategy = 'json-ld';\n } else if (r.jsonLdEvents > 0) {\n platform = 'json-ld';\n strategy = 'json-ld';\n }\n\n return {\n url,\n title,\n platform,\n strategy,\n nextDataPaths,\n signals: r,\n probedAt: new Date().toISOString(),\n };\n}\n\nconst result = probe();\n\nif (outDir) {\n mkdirSync(outDir, { recursive: true });\n const path = join(outDir, 'recon.json');\n writeFileSync(path, JSON.stringify(result, null, 2));\n console.error(`recon.json written → ${path}`);\n}\nconsole.log(JSON.stringify(result, null, 2));\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4275,"content_sha256":"1e7acdf79f4a783b66b3a9ed73b4bb827f0da869358c34097d0e89992f5f0b4e"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Event Prospecting","type":"text"}]},{"type":"paragraph","content":[{"text":"Take a conference URL → get a ranked list of people the AE should talk to, with a \"why reach out\" rationale per person.","type":"text"}]},{"type":"paragraph","content":[{"text":"Required","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"BROWSERBASE_API_KEY","type":"text","marks":[{"type":"code_inline"}]},{"text":" env var and the ","type":"text"},{"text":"browse","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI installed (","type":"text"},{"text":"npm install -g browse","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Use ","type":"text"},{"text":"browse cloud ...","type":"text","marks":[{"type":"code_inline"}]},{"text":" for API calls and ","type":"text"},{"text":"browse open","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"browse get markdown","type":"text","marks":[{"type":"code_inline"}]},{"text":" for JS-heavy speaker pages.","type":"text"}]},{"type":"paragraph","content":[{"text":"Path rules","type":"text","marks":[{"type":"strong"}]},{"text":": Always use the full literal path in all Bash commands — NOT ","type":"text"},{"text":"~","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"$HOME","type":"text","marks":[{"type":"code_inline"}]},{"text":" (both trigger \"shell expansion syntax\" approval prompts). Resolve the home directory once and use it everywhere. When constructing subagent prompts, replace ","type":"text"},{"text":"{SKILL_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the full literal path (typically ","type":"text"},{"text":"/Users/jay/skills/skills/event-prospecting","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"paragraph","content":[{"text":"Output directory","type":"text","marks":[{"type":"strong"}]},{"text":": All event prospecting output goes to ","type":"text"},{"text":"~/Desktop/{event_slug}_prospects_{YYYY-MM-DD-HHMM}/","type":"text","marks":[{"type":"code_inline"}]},{"text":". Final deliverable is ","type":"text"},{"text":"index.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" (people grouped by company, ranked by company ICP), with ","type":"text"},{"text":"companies.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"people.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" (filterable) as alternate views, plus ","type":"text"},{"text":"results.csv","type":"text","marks":[{"type":"code_inline"}]},{"text":" for cold-outbound import.","type":"text"}]},{"type":"paragraph","content":[{"text":"CRITICAL — Tool restrictions (applies to main agent AND all subagents)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"All web searches: use ","type":"text"},{"text":"browse cloud search","type":"text","marks":[{"type":"code_inline"}]},{"text":". NEVER use WebSearch.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"All page content extraction: use ","type":"text"},{"text":"node {SKILL_DIR}/scripts/extract_page.mjs \"\u003curl>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":". This script fetches via ","type":"text"},{"text":"browse cloud fetch --output","type":"text","marks":[{"type":"code_inline"}]},{"text":", parses title + meta tags + visible body text, and automatically falls back to ","type":"text"},{"text":"browse get markdown","type":"text","marks":[{"type":"code_inline"}]},{"text":" when fetch fails or returns thin JS-rendered content. NEVER hand-roll a ","type":"text"},{"text":"browse cloud fetch | sed","type":"text","marks":[{"type":"code_inline"}]},{"text":" pipeline. NEVER use WebFetch.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"All research output: subagents write ","type":"text"},{"text":"one markdown file per company OR per person","type":"text","marks":[{"type":"strong"}]},{"text":" to ","type":"text"},{"text":"{OUTPUT_DIR}/companies/{slug}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"{OUTPUT_DIR}/people/{slug}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" using bash heredoc. NEVER use the Write tool or ","type":"text"},{"text":"python3 -c","type":"text","marks":[{"type":"code_inline"}]},{"text":". See ","type":"text"},{"text":"references/example-research.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for both file formats.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Report compilation: use ","type":"text"},{"text":"node {SKILL_DIR}/scripts/compile_report.mjs {OUTPUT_DIR} --open","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Subagents must use ONLY the Bash tool. No other tools allowed.","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"HARD TOOL-CALL CAPS","type":"text","marks":[{"type":"strong"}]},{"text":": ICP triage = 1 call/company; deep research = 5 calls/company; person enrichment = 4 calls/person. See ","type":"text"},{"text":"references/workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for enforcement detail.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"CRITICAL — Anti-hallucination rules (applies to main agent AND all subagents)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"NEVER infer ","type":"text"},{"text":"product_description","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"industry","type":"text","marks":[{"type":"code_inline"}]},{"text":", or a person's ","type":"text"},{"text":"role_reason","type":"text","marks":[{"type":"code_inline"}]},{"text":" from a site's fonts, framework, design system, or typography. These are cosmetic and say nothing about what the company sells or what the person does.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"NEVER let the user's own ICP leak into a target's description. If you don't know what the target does, write ","type":"text"},{"text":"Unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" — do not pattern-match them onto the ICP.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"product_description","type":"text","marks":[{"type":"code_inline"}]},{"text":" MUST quote or paraphrase a specific phrase from ","type":"text"},{"text":"extract_page.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" output. If none of TITLE/META/OG/HEADINGS/BODY yield a recognizable product statement, write ","type":"text"},{"text":"Unknown — homepage content not accessible","type":"text","marks":[{"type":"code_inline"}]},{"text":" and cap ","type":"text"},{"text":"icp_fit_score","type":"text","marks":[{"type":"code_inline"}]},{"text":" at 3.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A person's ","type":"text"},{"text":"hook","type":"text","marks":[{"type":"code_inline"}]},{"text":" MUST quote or paraphrase a specific finding from a ","type":"text"},{"text":"browse cloud search","type":"text","marks":[{"type":"code_inline"}]},{"text":" result (podcast title, blog headline, GitHub repo, talk abstract). If no public signal exists in the last 6 months, fall back to event-context (their talk title at this event).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"CRITICAL — Minimize permission prompts","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Subagents MUST batch ALL file writes into a SINGLE Bash call using chained heredocs. One Bash call = one permission prompt.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Batch ALL searches and ALL fetches into single Bash calls using ","type":"text"},{"text":"&&","type":"text","marks":[{"type":"code_inline"}]},{"text":" chaining.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Pipeline Overview","type":"text"}]},{"type":"paragraph","content":[{"text":"Follow these 10 steps in order. Do not skip steps or reorder.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Setup","type":"text","marks":[{"type":"strong"}]},{"text":" — output dir + clean slate","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Load profile","type":"text","marks":[{"type":"strong"}]},{"text":" — read ","type":"text"},{"text":"profiles/{user_slug}.json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Recon","type":"text","marks":[{"type":"strong"}]},{"text":" — detect event platform","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Extract people","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"people.jsonl","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Group by company","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ICP triage","type":"text","marks":[{"type":"strong"}]},{"text":" — fast company-level scoring (1 call/company)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Filter","type":"text","marks":[{"type":"strong"}]},{"text":" — companies with ","type":"text"},{"text":"icp_fit_score >= --icp-threshold","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deep research","type":"text","marks":[{"type":"strong"}]},{"text":" — full Plan→Research→Synthesize on ICP fits","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Enrich speakers","type":"text","marks":[{"type":"strong"}]},{"text":" — ask user: ICP-fit only (default) or all speakers","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Compile report","type":"text","marks":[{"type":"strong"}]},{"text":" — HTML + CSV, open in browser","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The user invokes the skill with a URL like ","type":"text"},{"text":"/event-prospecting \u003cURL>","type":"text","marks":[{"type":"code_inline"}]},{"text":". Parse ","type":"text"},{"text":"EVENT_URL","type":"text","marks":[{"type":"code_inline"}]},{"text":" from that invocation message. Defaults: ","type":"text"},{"text":"DEPTH=deep","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ICP_THRESHOLD=6","type":"text","marks":[{"type":"code_inline"}]},{"text":". The ","type":"text"},{"text":"USER_SLUG","type":"text","marks":[{"type":"code_inline"}]},{"text":" (ICP profile) is auto-resolved in Step 1 from whatever profile files exist locally — there is no built-in default profile. Do NOT ask the user to confirm the URL — they already gave you it.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 0: Setup Output Directory","type":"text"}]},{"type":"paragraph","content":[{"text":"Derive the output directory from the URL the user gave you. Do NOT hardcode any event name.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# EVENT_URL came from the invocation message (whatever the user typed after `/event-prospecting`)\nEVENT_SLUG=$(node -e 'const h = new URL(process.argv[1]).hostname.replace(/^www\\./,\"\"); console.log(h.split(\".\")[0])' \"$EVENT_URL\")\nTIMESTAMP=$(date +%Y-%m-%d-%H%M)\nOUTPUT_DIR=/Users/jay/Desktop/${EVENT_SLUG}_prospects_${TIMESTAMP}\nmkdir -p \"$OUTPUT_DIR/companies\" \"$OUTPUT_DIR/people\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Use the full literal home path — never ","type":"text"},{"text":"~","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"$HOME","type":"text","marks":[{"type":"code_inline"}]},{"text":". Pass ","type":"text"},{"text":"{OUTPUT_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":" as the full literal path to all subagent prompts.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 1: Load User Profile","type":"text"}]},{"type":"paragraph","content":[{"text":"The profile defines the ICP that ICP triage and deep research score against. Load from ","type":"text"},{"text":"{SKILL_DIR}/profiles/{user_slug}.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (interchangeable across all GTM skills — same shape as company-research). ","type":"text"},{"text":"example.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a template, not a real profile — never use it.","type":"text"}]},{"type":"paragraph","content":[{"text":"DO NOT look outside ","type":"text","marks":[{"type":"strong"}]},{"text":"{SKILL_DIR}/profiles/","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for profiles — never reach into other skills' directories. If a profile is needed elsewhere, the user copies it explicitly.","type":"text"}]},{"type":"paragraph","content":[{"text":"Resolution order","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the user invoked with ","type":"text"},{"text":"--user-company \u003cslug>","type":"text","marks":[{"type":"code_inline"}]},{"text":", use that slug.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Else, list ","type":"text"},{"text":"profiles/*.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" excluding ","type":"text"},{"text":"example.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". If exactly one profile exists, use it (and tell the user which one). If multiple exist, ask the user (plain chat) which one.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If zero profiles exist, ","type":"text"},{"text":"fail loudly","type":"text","marks":[{"type":"strong"}]},{"text":" and instruct the user to create one (copy ","type":"text"},{"text":"profiles/example.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"profiles/\u003cyour_slug>.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" and fill it in, or run the company-research skill which builds one automatically).","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"PROFILES=$(ls {SKILL_DIR}/profiles/*.json 2>/dev/null | xargs -n1 basename | sed 's/\\.json$//' | grep -v '^example

Event Prospecting Take a conference URL → get a ranked list of people the AE should talk to, with a "why reach out" rationale per person. Required : env var and the CLI installed ( ). Use for API calls and / for JS-heavy speaker pages. Path rules : Always use the full literal path in all Bash commands — NOT or (both trigger "shell expansion syntax" approval prompts). Resolve the home directory once and use it everywhere. When constructing subagent prompts, replace with the full literal path (typically ). Output directory : All event prospecting output goes to . Final deliverable is (people gr…

)\nCOUNT=$(echo \"$PROFILES\" | grep -c .)\n\nif [ -z \"$USER_SLUG\" ]; then\n if [ \"$COUNT\" -eq 0 ]; then\n echo \"No profiles found in {SKILL_DIR}/profiles/. Copy profiles/example.json to profiles/\u003cyour_slug>.json and fill it in, or run the company-research skill to build one.\"\n exit 1\n elif [ \"$COUNT\" -eq 1 ]; then\n USER_SLUG=$PROFILES\n echo \"Using the only profile available: ${USER_SLUG}\"\n else\n echo \"Multiple profiles found:\"\n echo \"$PROFILES\" | sed 's/^/ - /'\n echo \"Re-invoke with --user-company \u003cslug> to pick one.\"\n exit 1\n fi\nfi\n\ntest -f {SKILL_DIR}/profiles/${USER_SLUG}.json || {\n echo \"Profile not found: profiles/${USER_SLUG}.json\"\n exit 1\n}\ncat {SKILL_DIR}/profiles/${USER_SLUG}.json","type":"text"}]},{"type":"paragraph","content":[{"text":"The profile yields: ","type":"text"},{"text":"company","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"product","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"icp_description","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"existing_customers","type":"text","marks":[{"type":"code_inline"}]},{"text":". These get embedded verbatim in every subagent prompt downstream.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 2: Recon","type":"text"}]},{"type":"paragraph","content":[{"text":"Detect the event platform and extraction strategy. One command:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node {SKILL_DIR}/scripts/recon.mjs {EVENT_URL} {OUTPUT_DIR}","type":"text"}]},{"type":"paragraph","content":[{"text":"Writes ","type":"text"},{"text":"{OUTPUT_DIR}/recon.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"platform","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"strategy","type":"text","marks":[{"type":"code_inline"}]},{"text":", and (for Next.js) ","type":"text"},{"text":"nextDataPaths","type":"text","marks":[{"type":"code_inline"}]},{"text":". See ","type":"text"},{"text":"references/event-platforms.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the platform catalog and detection priority.","type":"text"}]},{"type":"paragraph","content":[{"text":"Expected outcomes:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stripe Sessions class (Next.js): ","type":"text"},{"text":"platform: \"next-data\"","type":"text","marks":[{"type":"code_inline"}]},{"text":", 1-3 paths","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sessionize: ","type":"text"},{"text":"platform: \"sessionize\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lu.ma / Eventbrite: ","type":"text"},{"text":"platform: \"luma\" | \"eventbrite\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Anything else: ","type":"text"},{"text":"platform: \"custom\"","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"strategy: \"markdown\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (best-effort fallback)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 3: Extract People","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node {SKILL_DIR}/scripts/extract_event.mjs {OUTPUT_DIR} --user-company {USER_SLUG}","type":"text"}]},{"type":"paragraph","content":[{"text":"Reads ","type":"text"},{"text":"recon.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", dispatches to the platform-specific extractor, writes ","type":"text"},{"text":"people.jsonl","type":"text","marks":[{"type":"code_inline"}]},{"text":" (one speaker per line) and ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":" (deduped companies).","type":"text"}]},{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"--user-company","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag also drops the host-org's own employees (a Stripe-hosted event drops Stripe employees) and the user's own employees from the speaker list — those aren't prospects.","type":"text"}]},{"type":"paragraph","content":[{"text":"Sanity-check the output:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"wc -l {OUTPUT_DIR}/people.jsonl {OUTPUT_DIR}/seed_companies.txt\nhead -3 {OUTPUT_DIR}/people.jsonl","type":"text"}]},{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"people.jsonl","type":"text","marks":[{"type":"code_inline"}]},{"text":" is empty or under ~10 lines, recon picked the wrong platform — see ","type":"text"},{"text":"references/event-platforms.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and re-run with adjusted strategy.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 4: Group by Company","type":"text"}]},{"type":"paragraph","content":[{"text":"extract_event.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" emits ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":" already (one company per line, deduped, sorted). This step is informational — verify the count looks reasonable before fanning out:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"wc -l {OUTPUT_DIR}/seed_companies.txt","type":"text"}]},{"type":"paragraph","content":[{"text":"Expected: roughly 0.4-0.6× the speaker count (most events have ~2 speakers per company on average, some companies send 5+, many send 1).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 5: ICP Triage","type":"text"}]},{"type":"paragraph","content":[{"text":"Fast pass — one tool call per company, no deep research.","type":"text","marks":[{"type":"strong"}]},{"text":" Score every company in ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":" against the user's ICP and write a thin triage stub to ","type":"text"},{"text":"companies/{slug}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Companies with ","type":"text"},{"text":"icp_fit_score >= --icp-threshold","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default 6) advance to Step 7's deep research; the rest stay as triage stubs.","type":"text"}]},{"type":"paragraph","content":[{"text":"Dispatch pattern","type":"text","marks":[{"type":"strong"}]},{"text":": split ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":" into batches of ~10 and fan out N subagents in a SINGLE Agent batch (multiple Agent tool calls in one message). Each subagent runs the prompt from ","type":"text"},{"text":"references/workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"ICP Triage\" section. Hard cap: ","type":"text"},{"text":"1 tool call per company","type":"text","marks":[{"type":"strong"}]},{"text":" (just ","type":"text"},{"text":"extract_page.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the homepage), enforced via the ","type":"text"},{"text":"# browse call N/1","type":"text","marks":[{"type":"code_inline"}]},{"text":" comment pattern.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Build batch files: each batch line is \"name|guessed_homepage|slug\".\n# extract_event.mjs only emits company NAMES (no URLs), so we slugify and guess\n# https://{slug-without-spaces}.com as the canonical homepage. The triage subagent\n# is allowed to write product_description: \"Unknown — homepage content not accessible\"\n# and cap score at 3 if the guessed URL 404s — that's the documented fallback in\n# workflow.md (rule 3 of the ICP Triage prompt). Burning a real browse cloud search to\n# discover the URL would bust the 1-call-per-company HARD CAP.\nnode -e '\nconst fs = require(\"fs\");\nconst slugify = (s) => (s || \"\").toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\");\nconst seed = fs.readFileSync(\"{OUTPUT_DIR}/seed_companies.txt\", \"utf-8\").split(\"\\n\").filter(Boolean);\nconst lines = seed.map(c => {\n const slug = slugify(c);\n const guessedHost = c.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n return `${c}|https://${guessedHost}.com|${slug}`;\n});\nfs.writeFileSync(\"{OUTPUT_DIR}/_seed_with_urls.txt\", lines.join(\"\\n\") + \"\\n\");\n'\n\n# Split into ~10-company batches\nsplit -l 10 {OUTPUT_DIR}/_seed_with_urls.txt {OUTPUT_DIR}/_batch_triage_\n\n# Count batches → number of subagents to dispatch (cap at 6 per message; second wave for the rest)\nls {OUTPUT_DIR}/_batch_triage_* | wc -l","type":"text"}]},{"type":"paragraph","content":[{"text":"Then in a single message, dispatch one Agent call per batch (up to 6 in parallel; subsequent waves after the first returns). Each Agent gets the prompt from ","type":"text"},{"text":"references/workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"ICP Triage\" with these substitutions before sending:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{SKILL_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → full literal skill path (e.g. ","type":"text"},{"text":"/Users/jay/skills/skills/event-prospecting","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{OUTPUT_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → full literal output path","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{USER_COMPANY}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{USER_PRODUCT}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{ICP_DESCRIPTION}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → from the loaded profile","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{EVENT_NAME}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"recon.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":".title","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{COMPANY_LIST}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → contents of the batch file (e.g. ","type":"text"},{"text":"cat {OUTPUT_DIR}/_batch_triage_aa","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{TOTAL}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → number of lines in this batch (substitute into ","type":"text"},{"text":"# browse call N/{TOTAL}","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Agent dispatch (skeleton, repeat per batch in one message)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Agent(\n description: \"ICP triage batch aa\",\n prompt: \u003cICP Triage prompt from workflow.md with all placeholders substituted>,\n subagent_type: \"general-purpose\"\n)\nAgent(\n description: \"ICP triage batch ab\",\n prompt: \u003csame prompt template, COMPANY_LIST swapped to batch ab>,\n subagent_type: \"general-purpose\"\n)\n... up to 6 per message","type":"text"}]},{"type":"paragraph","content":[{"text":"After all subagents return, verify every company in ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":" has a corresponding ","type":"text"},{"text":"companies/{slug}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ls {OUTPUT_DIR}/companies/*.md | wc -l\n# Should equal `wc -l {OUTPUT_DIR}/seed_companies.txt`","type":"text"}]},{"type":"paragraph","content":[{"text":"Clean up the batch files: ","type":"text"},{"text":"rm {OUTPUT_DIR}/_batch_triage_*","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 6: Filter by ICP Threshold","type":"text"}]},{"type":"paragraph","content":[{"text":"Read each ","type":"text"},{"text":"companies/*.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" frontmatter, keep those with ","type":"text"},{"text":"icp_fit_score >= 6","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or whatever ","type":"text"},{"text":"--icp-threshold","type":"text","marks":[{"type":"code_inline"}]},{"text":" is). Write the surviving company slugs to ","type":"text"},{"text":"{OUTPUT_DIR}/icp_fits.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"THRESHOLD=6 # from --icp-threshold flag\nfor f in {OUTPUT_DIR}/companies/*.md; do\n score=$(awk '/^icp_fit_score:/{print $2; exit}' \"$f\")\n if [ -n \"$score\" ] && [ \"$score\" -ge \"$THRESHOLD\" ]; then\n basename \"$f\" .md\n fi\ndone > {OUTPUT_DIR}/icp_fits.txt\n\nwc -l {OUTPUT_DIR}/icp_fits.txt","type":"text"}]},{"type":"paragraph","content":[{"text":"Expected: 20-40% of ","type":"text"},{"text":"seed_companies.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":". If the survival rate is \u003c 10%, the threshold may be too high or the ICP description too narrow — surface a warning to the user.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 7: Deep Research","type":"text"}]},{"type":"paragraph","content":[{"text":"Full Plan→Research→Synthesize on ICP-fit companies only. Hard cap: ","type":"text"},{"text":"5 tool calls per company","type":"text","marks":[{"type":"strong"}]},{"text":" (homepage extract + 2-3 sub-question searches + 1-2 supplementary fetches). Subagents OVERWRITE the existing ","type":"text"},{"text":"companies/{slug}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" triage stub with the richer deep-research version (frontmatter ","type":"text"},{"text":"triage_only: false","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"paragraph","content":[{"text":"Dispatch pattern","type":"text","marks":[{"type":"strong"}]},{"text":": split ","type":"text"},{"text":"icp_fits.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":" into batches of ~5 (deep mode default) and fan out one Agent per batch in a SINGLE message (up to 6 Agents per message). Each Agent gets the prompt from ","type":"text"},{"text":"references/workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"Deep Research\" with these substitutions:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{SKILL_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{OUTPUT_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{USER_COMPANY}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{USER_PRODUCT}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{ICP_DESCRIPTION}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{EVENT_NAME}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (from ","type":"text"},{"text":"recon.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":".title","type":"text","marks":[{"type":"code_inline"}]},{"text":"), ","type":"text"},{"text":"{EVENT_CONTEXT}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (track / topic, manually inferred from the event homepage)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{COMPANY_LIST}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → contents of the batch file (each line ","type":"text"},{"text":"slug|website","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Build {company-slug|website} pairs by reading frontmatter from each triage stub\nwhile read slug; do\n website=$(awk '/^website:/{print $2; exit}' {OUTPUT_DIR}/companies/${slug}.md)\n echo \"${slug}|${website}\"\ndone \u003c {OUTPUT_DIR}/icp_fits.txt > {OUTPUT_DIR}/_deep_targets.txt\n\n# Split into ~5-company batches (deep mode)\nsplit -l 5 {OUTPUT_DIR}/_deep_targets.txt {OUTPUT_DIR}/_batch_deep_\nls {OUTPUT_DIR}/_batch_deep_* | wc -l","type":"text"}]},{"type":"paragraph","content":[{"text":"Agent dispatch (skeleton, repeat per batch in one message)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Agent(\n description: \"Deep research batch aa\",\n prompt: \u003cDeep Research prompt from workflow.md with all placeholders substituted; COMPANY_LIST = cat _batch_deep_aa>,\n subagent_type: \"general-purpose\"\n)\nAgent(\n description: \"Deep research batch ab\",\n prompt: \u003csame template, COMPANY_LIST = cat _batch_deep_ab>,\n subagent_type: \"general-purpose\"\n)\n... up to 6 per message; second wave after the first returns","type":"text"}]},{"type":"paragraph","content":[{"text":"After all subagents return, verify the deep-research files exist and have ","type":"text"},{"text":"triage_only: false","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"grep -l \"triage_only: false\" {OUTPUT_DIR}/companies/*.md | wc -l\n# Should equal wc -l icp_fits.txt","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 8: Enrich Speakers","type":"text"}]},{"type":"paragraph","content":[{"text":"Per person: harvest LinkedIn URL, recent activity (podcast / blog / talk / GitHub / X), and write ","type":"text"},{"text":"people/{slug}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Hard cap: ","type":"text"},{"text":"4 tool calls per person","type":"text","marks":[{"type":"strong"}]},{"text":", three lanes:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"browse cloud search \"{name} {company} linkedin\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (always)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"browse cloud search \"{name} podcast OR talk OR blog 2026\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (deep+)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"browse cloud search \"{name} github\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (deeper)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"browse cloud search \"{name} site:x.com OR site:twitter.com\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (deeper, best-effort)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Quick mode: skip Step 8 entirely. Deep mode: lanes 1-2. Deeper mode: lanes 1-4.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 8a — Ask the user: scope of enrichment","type":"text"}]},{"type":"paragraph","content":[{"text":"Before dispatching, compute the two candidate counts and ask the user to choose. The default is ","type":"text"},{"text":"ICP-fit only","type":"text","marks":[{"type":"strong"}]},{"text":" (faster, cheaper, what most users want); enriching every speaker is opt-in because cost scales linearly with people enriched.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"TOTAL=$(wc -l \u003c {OUTPUT_DIR}/people.jsonl)\nICP_FITS=$(node -e '\nconst fs = require(\"fs\");\nconst fits = new Set(fs.readFileSync(\"{OUTPUT_DIR}/icp_fits.txt\", \"utf-8\").split(\"\\n\").filter(Boolean));\nconst slug2name = {};\nfor (const slug of fits) {\n const md = fs.readFileSync(`{OUTPUT_DIR}/companies/${slug}.md`, \"utf-8\");\n const m = md.match(/^company_name:\\s*(.+)$/m);\n if (m) slug2name[slug] = m[1].trim();\n}\nconst want = new Set(Object.values(slug2name).map(s => s.toLowerCase()));\nconst ppl = fs.readFileSync(\"{OUTPUT_DIR}/people.jsonl\",\"utf-8\").split(\"\\n\").filter(Boolean).map(JSON.parse);\nconsole.log(ppl.filter(p => p.company && want.has(p.company.toLowerCase())).length);\n')\n\n# Lanes per person: 2 (deep) or 4 (deeper) — match {DEPTH}\nLANES=2 # or 4 for deeper\necho \"ICP fits: ${ICP_FITS} speakers × ${LANES} = $((ICP_FITS * LANES)) calls\"\necho \"All: ${TOTAL} speakers × ${LANES} = $((TOTAL * LANES)) calls\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Then ask via ","type":"text"},{"text":"AskUserQuestion","type":"text","marks":[{"type":"code_inline"}]},{"text":" — clean two-option choice with the quantified cost on each:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"AskUserQuestion(questions: [\n {\n question: \"Enrich which speakers?\",\n header: \"Enrichment scope\",\n multiSelect: false,\n options: [\n { label: \"ICP fits only\", description: \"${ICP_FITS} speakers, ~$((ICP_FITS * LANES)) calls (recommended)\" },\n { label: \"All speakers\", description: \"${TOTAL} speakers, ~$((TOTAL * LANES)) calls\" }\n ]\n }\n])","type":"text"}]},{"type":"paragraph","content":[{"text":"Save the chosen scope as ","type":"text"},{"text":"ENRICH_SCOPE=icp_fits","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"ENRICH_SCOPE=all","type":"text","marks":[{"type":"code_inline"}]},{"text":". If the user picks \"All speakers\" and ","type":"text"},{"text":"TOTAL × LANES > 600","type":"text","marks":[{"type":"code_inline"}]},{"text":", print a warning and ask once more — that's a 10+ minute run with hundreds of tool calls.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 8b — Filter and batch","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Build _people_to_enrich.jsonl based on ENRICH_SCOPE\nif [ \"$ENRICH_SCOPE\" = \"all\" ]; then\n cp {OUTPUT_DIR}/people.jsonl {OUTPUT_DIR}/_people_to_enrich.jsonl\nelse\n node -e '\nconst fs = require(\"fs\");\nconst fits = new Set(fs.readFileSync(\"{OUTPUT_DIR}/icp_fits.txt\", \"utf-8\").split(\"\\n\").filter(Boolean));\nconst slug2name = {};\nfor (const slug of fits) {\n const md = fs.readFileSync(`{OUTPUT_DIR}/companies/${slug}.md`, \"utf-8\");\n const m = md.match(/^company_name:\\s*(.+)$/m);\n if (m) slug2name[slug] = m[1].trim();\n}\nconst wantNames = new Set(Object.values(slug2name).map(s => s.toLowerCase()));\nconst lines = fs.readFileSync(\"{OUTPUT_DIR}/people.jsonl\", \"utf-8\").split(\"\\n\").filter(Boolean);\nconst keep = lines.filter(l => {\n const p = JSON.parse(l);\n return p.company && wantNames.has(p.company.toLowerCase());\n});\nfs.writeFileSync(\"{OUTPUT_DIR}/_people_to_enrich.jsonl\", keep.join(\"\\n\") + \"\\n\");\nconsole.error(`Enriching ${keep.length} of ${lines.length} speakers`);\n'\nfi\n\n# Split into ~5-person batches\nsplit -l 5 {OUTPUT_DIR}/_people_to_enrich.jsonl {OUTPUT_DIR}/_batch_people_","type":"text"}]},{"type":"paragraph","content":[{"text":"Then in a single message, dispatch one Agent call per batch (up to 6 per message) with the prompt from ","type":"text"},{"text":"references/workflow.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"Person Enrichment\". Each subagent's prompt should include:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{SKILL_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{OUTPUT_DIR}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{DEPTH}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"deep","type":"text","marks":[{"type":"code_inline"}]},{"text":" | ","type":"text"},{"text":"deeper","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{USER_COMPANY}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{USER_PRODUCT}","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{ICP_DESCRIPTION}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{EVENT_NAME}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (from ","type":"text"},{"text":"recon.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":".title","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{LANES}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"2","type":"text","marks":[{"type":"code_inline"}]},{"text":" for deep mode, ","type":"text"},{"text":"4","type":"text","marks":[{"type":"code_inline"}]},{"text":" for deeper mode (substituted into ","type":"text"},{"text":"# browse call N/{LANES}","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{PEOPLE_BATCH}","type":"text","marks":[{"type":"code_inline"}]},{"text":" → contents of ","type":"text"},{"text":"_batch_people_aa","type":"text","marks":[{"type":"code_inline"}]},{"text":" (each line a JSON record from ","type":"text"},{"text":"people.jsonl","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Agent dispatch (skeleton, repeat per batch in one message)","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Agent(\n description: \"Person enrichment batch aa\",\n prompt: \u003cPerson Enrichment prompt from workflow.md with all placeholders substituted; PEOPLE_BATCH = cat _batch_people_aa>,\n subagent_type: \"general-purpose\"\n)\nAgent(\n description: \"Person enrichment batch ab\",\n prompt: \u003csame template, PEOPLE_BATCH = cat _batch_people_ab>,\n subagent_type: \"general-purpose\"\n)\n... up to 6 per message","type":"text"}]},{"type":"paragraph","content":[{"text":"After all subagents return, verify the people files exist:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ls {OUTPUT_DIR}/people/*.md | wc -l\n# Should equal wc -l _people_to_enrich.jsonl","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 9: Compile Report","type":"text"}]},{"type":"paragraph","content":[{"text":"Generate the company-grouped HTML index, alternate views, and CSV in one command:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node {SKILL_DIR}/scripts/compile_report.mjs {OUTPUT_DIR} --open","type":"text"}]},{"type":"paragraph","content":[{"text":"This generates:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{OUTPUT_DIR}/index.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" — people grouped by company, ranked by company ICP score (opens in browser)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{OUTPUT_DIR}/people.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" — filterable speaker list (alternate view)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{OUTPUT_DIR}/companies.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ICP-ranked company table with attendees","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{OUTPUT_DIR}/results.csv","type":"text","marks":[{"type":"code_inline"}]},{"text":" — cold-outbound-ready spreadsheet","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Then present a summary in chat:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"## Event Prospecting Complete — {Event Name}\n\n- **Total speakers extracted**: {count}\n- **Unique companies**: {count}\n- **ICP fits (score ≥ {threshold})**: {count}\n- **Speakers enriched**: {count}\n- **Score distribution** (companies):\n - Strong fit (8-10): {count}\n - Partial fit (5-7): {count}\n - Weak fit (1-4): {count}\n- **Report opened in browser**: {OUTPUT_DIR}/index.html","type":"text"}]},{"type":"paragraph","content":[{"text":"Show the ","type":"text"},{"text":"top 5 people cards","type":"text","marks":[{"type":"strong"}]},{"text":" as a markdown table sorted by company ICP score, then offer to:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Adjust ","type":"text"},{"text":"--icp-threshold","type":"text","marks":[{"type":"code_inline"}]},{"text":" and re-run Steps 6-9","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Export the CSV to a CRM","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"event-prospecting","author":"@skillopedia","source":{"stars":3747,"repo_name":"skills","origin_url":"https://github.com/browserbase/skills/blob/HEAD/skills/event-prospecting/SKILL.md","repo_owner":"browserbase","body_sha256":"5e921191a41d323524fd7062973b94fd07b407fe847defb6efec697047c30265","cluster_key":"e1a1befb533333a91d9dcfc127b9b05add5d30e32372119460475e3c4780be00","clean_bundle":{"format":"clean-skill-bundle-v1","source":"browserbase/skills/skills/event-prospecting/SKILL.md","text_bytes":123153,"source_commit":"6811ca31163332d9d60309cff48e77f09de37a17","skills_sh_rank":8231,"attachment_count":12,"text_attachments":12,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/event-prospecting/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"business-sales-marketing","category_label":"Business"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"business-sales-marketing","metadata":{"author":"browserbase","version":"0.1.0"},"import_tag":"skills-sh-top20k-2026-11","description":"Event prospecting skill. Takes a conference / event speakers URL,\nextracts the people, filters their companies against the user's\nICP, then deep-researches only the speakers at ICP-fit companies.\nOutputs a person-first HTML report where each card answers \"why\nshould the AE talk to this person?\" with all public links and a\none-click DM opener.\nUse when the user wants to: (1) find leads at a specific\nconference, (2) prep for an event, (3) research event speakers,\n(4) build a target list from a sponsor/exhibitor page,\n(5) scrape conference speakers and rank by ICP fit.\nTriggers: \"find leads at {event}\", \"research speakers at\",\n\"prospect this conference\", \"stripe sessions leads\",\n\"ai engineer summit prospects\", \"event prospecting\",\n\"scrape conference speakers\", \"who should I meet at\".\n","allowed-tools":"Bash Agent AskUserQuestion","compatibility":"Requires browse CLI (`npm install -g browse`) and BROWSERBASE_API_KEY env var. The same `browse` binary covers both API commands and JS-rendered page fallback."}},"renderedAt":1793661673107}

Event Prospecting Take a conference URL → get a ranked list of people the AE should talk to, with a "why reach out" rationale per person. Required : env var and the CLI installed ( ). Use for API calls and / for JS-heavy speaker pages. Path rules : Always use the full literal path in all Bash commands — NOT or (both trigger "shell expansion syntax" approval prompts). Resolve the home directory once and use it everywhere. When constructing subagent prompts, replace with the full literal path (typically ). Output directory : All event prospecting output goes to . Final deliverable is (people gr…