From f4a13d5146e3c01c864107a92f2678a2b78b9894 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:37:28 +0000 Subject: [PATCH] fix(user-interviews): guard null emails in behavioral targeting templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three "Finding users by behavior" SQL templates in the planning-voice-agent-user-interviews skill grouped events by a single `` placeholder that agents were told to fill with `person.properties.email`. With no null guard, all emailless (anonymous) traffic collapsed into one `None` residual row that sorts to the top under `ORDER BY count() DESC`, eating a slot of the 20-row interview sample and risking a literal `None` being passed downstream as an interviewee email. The prose also said to "keep both kinds of rows" (email and distinct_id), but a single `` column can't do that in one run. Rewrite the templates to group by `coalesce(person.properties.email, distinct_id) AS id` and select an explicit `email` column. This keeps both kinds of rows in a single query — emailed people group under their email, emailless people fall back to their own distinct_id as separate rows instead of one junk bucket — and the `email` column gives an unambiguous routing rule (non-null → `interviewee_emails`, null → `interviewee_distinct_ids`), mirroring the null guard the same file already uses in its cohort recipe. Also update Step 5's CSV guidance to prefer the existing `user-interview-topics-interviewees-bulk-create` tool over one create call per row. Generated-By: PostHog Code Task-Id: 8e4de988-d9c9-4628-9d1d-7cb1d7e350a3 --- .../planning-voice-agent-user-interviews/SKILL.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/products/user_interviews/skills/planning-voice-agent-user-interviews/SKILL.md b/products/user_interviews/skills/planning-voice-agent-user-interviews/SKILL.md index d1b6394d9bda..18968f712ed2 100644 --- a/products/user_interviews/skills/planning-voice-agent-user-interviews/SKILL.md +++ b/products/user_interviews/skills/planning-voice-agent-user-interviews/SKILL.md @@ -100,13 +100,13 @@ Topics snapshot their audience at create time. When the user picks a cohort, you When the user describes who they want to talk to in behavioral terms, find them in the project's own data: 1. **Find the right event.** Call `read-data-schema` to list events that actually exist in the project. Don't guess event names from training data — PostHog event taxonomies are bespoke. Match the user's description to one or two candidate events; if multiple plausible matches exist, list them and ask which behavior they care about. -2. **Query for users.** Call `execute-sql` with HogQL. Filter by the chosen event over the last 60 days, group by person — prefer `person.properties.email` (directly usable as `interviewee_emails`), fall back to `distinct_id` (for `interviewee_distinct_ids`). Keep both kinds of rows. The aggregates in each template (`event_count`, `last_seen`, `days_since_last_seen`) are what feed Step 5's per-interviewee context. Replace `` with the chosen event, and `` with `person.properties.email` or `distinct_id`: - - **Heavy users** — `SELECT AS id, count() AS event_count, max(timestamp) AS last_seen, dateDiff('day', max(timestamp), now()) AS days_since_last_seen FROM events WHERE event = '' AND timestamp > now() - INTERVAL 60 DAY GROUP BY HAVING count() >= 5 ORDER BY count() DESC LIMIT 20` - - **Drop-offs** (tried once or twice and never came back) — `SELECT AS id, count() AS event_count, max(timestamp) AS last_seen, dateDiff('day', max(timestamp), now()) AS days_since_last_seen FROM events WHERE event = '' AND timestamp > now() - INTERVAL 60 DAY GROUP BY HAVING count() <= 2 AND dateDiff('day', max(timestamp), now()) > 14 ORDER BY count() ASC LIMIT 20` - - **At-risk** (was active, now dormant) — `SELECT AS id, count() AS event_count, max(timestamp) AS last_seen, dateDiff('day', max(timestamp), now()) AS days_since_last_seen FROM events WHERE event = '' AND timestamp > now() - INTERVAL 60 DAY GROUP BY HAVING count() >= 3 AND dateDiff('day', max(timestamp), now()) > 14 ORDER BY days_since_last_seen DESC LIMIT 20` +2. **Query for users.** Call `execute-sql` with HogQL. Filter by the chosen event over the last 60 days and group per person with `coalesce(person.properties.email, distinct_id) AS id` — this keeps both kinds of rows in a single query: people with an email group under it (directly usable as `interviewee_emails`), while emailless people fall back to their own `distinct_id` (for `interviewee_distinct_ids`) instead of collapsing into one junk `None` bucket that would sort to the top under `ORDER BY count() DESC` and eat a slot of the sample. The selected `email` column tells you which is which when routing (see below). The aggregates in each template (`event_count`, `last_seen`, `days_since_last_seen`) are what feed Step 5's per-interviewee context. Replace `` with the chosen event: + - **Heavy users** — `SELECT coalesce(person.properties.email, distinct_id) AS id, person.properties.email AS email, count() AS event_count, max(timestamp) AS last_seen, dateDiff('day', max(timestamp), now()) AS days_since_last_seen FROM events WHERE event = '' AND timestamp > now() - INTERVAL 60 DAY GROUP BY id, email HAVING count() >= 5 ORDER BY count() DESC LIMIT 20` + - **Drop-offs** (tried once or twice and never came back) — `SELECT coalesce(person.properties.email, distinct_id) AS id, person.properties.email AS email, count() AS event_count, max(timestamp) AS last_seen, dateDiff('day', max(timestamp), now()) AS days_since_last_seen FROM events WHERE event = '' AND timestamp > now() - INTERVAL 60 DAY GROUP BY id, email HAVING count() <= 2 AND dateDiff('day', max(timestamp), now()) > 14 ORDER BY count() ASC LIMIT 20` + - **At-risk** (was active, now dormant) — `SELECT coalesce(person.properties.email, distinct_id) AS id, person.properties.email AS email, count() AS event_count, max(timestamp) AS last_seen, dateDiff('day', max(timestamp), now()) AS days_since_last_seen FROM events WHERE event = '' AND timestamp > now() - INTERVAL 60 DAY GROUP BY id, email HAVING count() >= 3 AND dateDiff('day', max(timestamp), now()) > 14 ORDER BY days_since_last_seen DESC LIMIT 20` 3. **Build a balanced sample.** Unless the user asked for one specific segment, mixing 5 heavy users + 3 drop-offs + 2 at-risk users yields the most actionable interviews: you learn what works, what blocks adoption, and what breaks retention. Adjust counts to match what the user actually wants. -Pass the email rows as `interviewee_emails` and the distinct-ID rows as `interviewee_distinct_ids` — both can be set on the same topic. Keep `event_count` and `days_since_last_seen` per person so Step 5 can synthesise context like "used checkout 47 times in last 60 days; last seen 2 days ago". +Route each row by its `email` column: rows where `email` is non-null go into `interviewee_emails`, and rows where `email` is null (so `id` holds the `distinct_id`) go into `interviewee_distinct_ids` — both can be set on the same topic. Keep `event_count` and `days_since_last_seen` per person so Step 5 can synthesise context like "used checkout 47 times in last 60 days; last seen 2 days ago". ## Step 3: Capture the topic @@ -217,7 +217,7 @@ identifier,context abc-distinct-id-1,churned from Scale last month — be empathetic ``` -Parse the CSV, then call `user-interview-topics-interviewees-create` once per row with the captured `topic_id`. Skip blank lines. Quote-escape commas inside the context cell — standard CSV rules. +Parse the CSV, then create the rows with the captured `topic_id`. For more than a couple of rows, prefer `user-interview-topics-interviewees-bulk-create` — it takes an `items` array of `(interviewee_identifier, agent_context)` and creates up to 500 rows in one request, reporting `inserted_count`, `skipped_count`, and `skipped_identifiers` for pairs that already exist. Fall back to `user-interview-topics-interviewees-create` for a single row. Skip blank lines. Quote-escape commas inside the context cell — standard CSV rules. If a row's identifier isn't present in the parent topic's `interviewee_emails` or `interviewee_distinct_ids`, warn the user before creating — the voice agent looks up context by exact string match, so a mismatched identifier just gets ignored at runtime.