From 401597020e7d3b4c23c425b9998e5edd52892b17 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:06:44 +0000 Subject: [PATCH 01/10] feat(skills): publish sending-transactional-emails Build the sending-transactional-emails skill from PostHog/posthog's SKILL.md and its reference, fetched at build time, with the transactional emails docs and a section for running it inside the Wizard. A docs_urls entry can now name its file, so the fetched SKILL.md lands as references/sending-transactional-emails.md. Co-Authored-By: Claude Opus 5.5 --- .../sending-transactional-emails/config.yaml | 18 +++++ .../description.md | 22 +++++++ scripts/lib/skill-generator.js | 5 +- ...sending-transactional-emails-skill.test.js | 43 ++++++++++++ .../skill-generator-doc-filename.test.js | 66 +++++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 context/skills/sending-transactional-emails/config.yaml create mode 100644 context/skills/sending-transactional-emails/description.md create mode 100644 scripts/lib/tests/sending-transactional-emails-skill.test.js create mode 100644 scripts/lib/tests/skill-generator-doc-filename.test.js diff --git a/context/skills/sending-transactional-emails/config.yaml b/context/skills/sending-transactional-emails/config.yaml new file mode 100644 index 000000000..abd99cd4d --- /dev/null +++ b/context/skills/sending-transactional-emails/config.yaml @@ -0,0 +1,18 @@ +# PostHog transactional emails - PostHog/posthog's sending-transactional-emails skill, fetched at build time, plus how to run it inside the Wizard +type: skill +template: description.md +category: workflows +description: Set up PostHog transactional emails, one-off emails that code sends to one person, such as password resets, sign-in codes, magic links, receipts, and invites. Use when asked to send one of those emails with PostHog, or to set up transactional email. It checks for a verified sender, gets a template made transactional in PostHog, hands over the project secret API key, and writes the send with posthog-node, the posthog Python SDK, or the HTTP API. +tags: [workflows] +shared_docs: + - https://posthog.com/docs/workflows/transactional-emails.md + - https://posthog.com/docs/workflows/transactional-emails/api.md +variants: + - id: all + display_name: all supported platforms + docs_urls: + - url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/SKILL.md + filename: sending-transactional-emails.md + title: The sending-transactional-emails skill. Read it first + - url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/references/moving-from-other-providers.md + title: Moving a send from another provider to PostHog diff --git a/context/skills/sending-transactional-emails/description.md b/context/skills/sending-transactional-emails/description.md new file mode 100644 index 000000000..d3afb7db9 --- /dev/null +++ b/context/skills/sending-transactional-emails/description.md @@ -0,0 +1,22 @@ +# Sending transactional emails + +This skill publishes PostHog's `sending-transactional-emails` skill, which PostHog keeps next to its product code. Read `references/sending-transactional-emails.md` first, and follow it. Where it links to `references/`, the file is in this skill's `references/` folder. The transactional emails docs are in the same folder: `transactional-emails.md` has every error code, and `api.md` has the HTTP send. + +## Inside the PostHog Wizard + +Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. + +- The person ran the Wizard to set up a transactional email, so skip "Offer it when the user mentions one". +- The Wizard has no chat. Ask every question of the skill with `wizard_ask`, never in plain text, and batch the questions of one step into one call. When the person did not say which email to send, ask them, and offer a welcome email after signup as the first choice. +- The PostHog tools of the skill run through `posthog_exec`. Check that a tool exists with `search `. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off. Don't stop at "Check that the project can use it". When a step of "Set it up" needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: + - **Verified sender:** they set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says. + - **Template and making it transactional:** they open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. Then ask for the template ID, which the email's **API** tab shows. +- **API key:** tell the person how to create the key, as step 7 of "Set it up" says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable. +- When the person says **Transactional** isn't in their PostHog project, or a test send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop. +- When the project has no server code that can send, emit `[ABORT] no server code found` and stop. +- **Test:** ask before the test send of step 9, and send only to an address the person owns. When they say no, skip it, and write in the report that no send has confirmed the setup. + +## Reference files + +{references} diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 454142acd..ffe6fae3e 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -719,16 +719,17 @@ async function generateSkill({ } } - // Helper to process a doc entry (string URL or {url, title} object). + // Helper to process a doc entry (string URL or {url, title, filename} object). // fetchDoc logs `Fetching doc:` only on a real network fetch — cache hits // are silent. async function processDoc(docEntry) { const url = typeof docEntry === 'string' ? docEntry : docEntry.url; const titleOverride = typeof docEntry === 'object' ? docEntry.title : null; + const filenameOverride = typeof docEntry === 'object' ? docEntry.filename : null; const result = await fetchDoc(url); if (result) { - const filename = urlToFilename(url); + const filename = filenameOverride || urlToFilename(url); fs.writeFileSync( path.join(referencesDir, filename), result.content, diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js new file mode 100644 index 000000000..ff94dbcb5 --- /dev/null +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -0,0 +1,43 @@ +/** + * The Wizard's `transactional-email` program runs this skill by its ID and + * matches the `[ABORT]` reasons of its Wizard section, so both are pinned here. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { expandSkillGroups, loadSkillsConfig } from '../skill-generator.js'; + +const CONFIG_DIR = join(process.cwd(), 'context'); +const DESCRIPTION = readFileSync( + join(CONFIG_DIR, 'skills', 'sending-transactional-emails', 'description.md'), + 'utf8', +); + +function skill() { + const config = loadSkillsConfig(CONFIG_DIR); + return expandSkillGroups(config, CONFIG_DIR).filter( + (s) => s._group === 'sending-transactional-emails', + ); +} + +describe('sending-transactional-emails skill', () => { + it('expands to the one skill ID the Wizard program runs', () => { + expect(skill().map((s) => s.id)).toEqual(['sending-transactional-emails']); + }); + + it('fetches the PostHog skill under its own name, so it is not taken for this SKILL.md', () => { + expect(skill()[0].docs_urls[0]).toMatchObject({ + url: 'https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/SKILL.md', + filename: 'sending-transactional-emails.md', + }); + expect(DESCRIPTION).toContain('Read `references/sending-transactional-emails.md` first'); + }); + + it.each(['transactional email api not enabled', 'no server code found'])( + 'keeps the "%s" abort the Wizard program matches', + (reason) => { + expect(DESCRIPTION).toContain(`emit \`[ABORT] ${reason}\` and stop`); + }, + ); +}); diff --git a/scripts/lib/tests/skill-generator-doc-filename.test.js b/scripts/lib/tests/skill-generator-doc-filename.test.js new file mode 100644 index 000000000..583f214e9 --- /dev/null +++ b/scripts/lib/tests/skill-generator-doc-filename.test.js @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +vi.hoisted(() => { + process.env.DOCS_CACHE_TTL_MS = '0'; +}); + +import { expandSkillGroups, generateSkill } from '../skill-generator.js'; + +const UPSTREAM_SKILL_URL = 'https://docs.example.test/skills/sending-email/SKILL.md'; + +describe('generateSkill doc filename', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'skills-test-')); + mkdirSync(join(tmpDir, 'skills', 'sending-email'), { recursive: true }); + writeFileSync( + join(tmpDir, 'skills', 'sending-email', 'description.md'), + '# Sending email\n\n## Reference files\n\n{references}\n', + ); + vi.stubGlobal('fetch', vi.fn(async () => new Response('# Upstream skill\n\nSteps'))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes a fetched doc under the filename its entry names', async () => { + const config = { + 'sending-email': { + type: 'skill', + template: 'description.md', + variants: [{ + id: 'all', + display_name: 'Sending email', + docs_urls: [{ url: UPSTREAM_SKILL_URL, filename: 'upstream-skill.md', title: 'The upstream skill' }], + }], + }, + }; + const skill = expandSkillGroups(config, tmpDir)[0]; + const outputDir = join(tmpDir, 'out'); + + await generateSkill({ + skill, + version: 'test', + repoRoot: tmpDir, + configDir: tmpDir, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: skill._template, + sharedDocs: skill._sharedDocs || [], + }); + + const references = join(outputDir, 'sending-email', 'references'); + expect(readFileSync(join(references, 'upstream-skill.md'), 'utf8')).toBe('# Upstream skill\n\nSteps'); + expect(existsSync(join(references, 'SKILL.md'))).toBe(false); + expect(readFileSync(join(outputDir, 'sending-email', 'SKILL.md'), 'utf8')).toContain( + '- `references/upstream-skill.md` - The upstream skill', + ); + }); +}); From f7760d9d2adc75fce484f836d6011f767d708f73 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:51:38 +0000 Subject: [PATCH 02/10] fix(skills): order the Wizard section of sending-transactional-emails, and check doc filenames From the review panel: - Check for server code before any question or PostHog step, so a frontend-only project aborts before the person sets anything up. - Name the PostHog skill's steps by their bold titles, not by number or heading, so an upstream reorder can't move them. - Say what to do when wizard_ask fails: skip the steps that need answers, list them in the report, and send nothing. - Send the test through the app's own send code, never reading the key. - Keep Protect sensitive data, the no-marketing-category rule and engagement events on the manual path. - A doc entry's filename must be a plain .md name no other reference uses. Co-Authored-By: Claude Opus 5.5 --- .../description.md | 18 +++++----- scripts/lib/skill-generator.js | 13 +++++++ ...sending-transactional-emails-skill.test.js | 14 +++++--- .../skill-generator-doc-filename.test.js | 36 ++++++++++++++----- 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/context/skills/sending-transactional-emails/description.md b/context/skills/sending-transactional-emails/description.md index d3afb7db9..8c3ee715b 100644 --- a/context/skills/sending-transactional-emails/description.md +++ b/context/skills/sending-transactional-emails/description.md @@ -4,18 +4,20 @@ This skill publishes PostHog's `sending-transactional-emails` skill, which PostH ## Inside the PostHog Wizard -Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. +Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. This section names the PostHog skill's steps by their bold titles, such as **API key.**, so find them by title, not by number. -- The person ran the Wizard to set up a transactional email, so skip "Offer it when the user mentions one". +- **First, look for server code.** Before any question or PostHog step, check that the project has server code that can send: a backend route, a server action, a worker or a job. When it has none, emit `[ABORT] no server code found` and stop. +- The person ran the Wizard to set up a transactional email, so skip the skill's advice on when to offer it. - The Wizard has no chat. Ask every question of the skill with `wizard_ask`, never in plain text, and batch the questions of one step into one call. When the person did not say which email to send, ask them, and offer a welcome email after signup as the first choice. +- **When `wizard_ask` fails.** When it errors or isn't available, don't ask in plain text, and don't send anything. Write the send code only when you already know the email, its variable names and the template ID. List every step you skipped in the report as one the person still has to do, and stop. - The PostHog tools of the skill run through `posthog_exec`. Check that a tool exists with `search `. -- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off. Don't stop at "Check that the project can use it". When a step of "Set it up" needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: - - **Verified sender:** they set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says. - - **Template and making it transactional:** they open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. Then ask for the template ID, which the email's **API** tab shows. -- **API key:** tell the person how to create the key, as step 7 of "Set it up" says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off: don't stop at the skill's check that the project can use them. When a step needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: + - **Verified sender.** They set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says. + - **Template**, **No marketing category.** and **Make it transactional.** They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key. + - **Engagement events.** Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. +- **API key.** Tell the person how to create the key, as the skill's **API key.** step says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable. +- **Test.** Ask before the skill's test send, and send only to an address the person owns. Send it through the app's new send code, for example with a one-off script that calls it, and never read, print or pass `POSTHOG_PROJECT_SECRET_API_KEY` yourself. When you can't run that code, ask the person to send it and tell you what came back. When they say no, skip it, and write in the report that no send has confirmed the setup. - When the person says **Transactional** isn't in their PostHog project, or a test send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop. -- When the project has no server code that can send, emit `[ABORT] no server code found` and stop. -- **Test:** ask before the test send of step 9, and send only to an address the person owns. When they say no, skip it, and write in the report that no send has confirmed the setup. ## Reference files diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index ffe6fae3e..4d08346c9 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -719,6 +719,17 @@ async function generateSkill({ } } + // A doc entry's `filename` must be a plain `.md` name that no other + // reference uses, or the fetched doc would overwrite it. + function checkDocFilename(filename, url) { + const taken = filename === 'COMMANDMENTS.md' || references.some((ref) => ref.filename === filename); + if (path.basename(filename) !== filename || !filename.endsWith('.md') || taken) { + throw new Error( + `${skill.id}: doc ${url} names filename "${filename}"; use a plain .md name no other reference uses` + ); + } + } + // Helper to process a doc entry (string URL or {url, title, filename} object). // fetchDoc logs `Fetching doc:` only on a real network fetch — cache hits // are silent. @@ -727,6 +738,8 @@ async function generateSkill({ const titleOverride = typeof docEntry === 'object' ? docEntry.title : null; const filenameOverride = typeof docEntry === 'object' ? docEntry.filename : null; + if (filenameOverride) checkDocFilename(filenameOverride, url); + const result = await fetchDoc(url); if (result) { const filename = filenameOverride || urlToFilename(url); diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js index ff94dbcb5..85ccf34ec 100644 --- a/scripts/lib/tests/sending-transactional-emails-skill.test.js +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -26,14 +26,18 @@ describe('sending-transactional-emails skill', () => { expect(skill().map((s) => s.id)).toEqual(['sending-transactional-emails']); }); - it('fetches the PostHog skill under its own name, so it is not taken for this SKILL.md', () => { - expect(skill()[0].docs_urls[0]).toMatchObject({ - url: 'https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/SKILL.md', - filename: 'sending-transactional-emails.md', - }); + it('reads the PostHog skill under the name its description points to', () => { + expect(skill()[0].docs_urls[0].filename).toBe('sending-transactional-emails.md'); expect(DESCRIPTION).toContain('Read `references/sending-transactional-emails.md` first'); }); + it('checks for server code before it asks anything', () => { + const wizardSection = DESCRIPTION.slice(DESCRIPTION.indexOf('## Inside the PostHog Wizard')); + const firstRule = wizardSection.split('\n- ')[1]; + expect(firstRule).toContain('emit `[ABORT] no server code found` and stop'); + expect(firstRule).not.toContain('wizard_ask'); + }); + it.each(['transactional email api not enabled', 'no server code found'])( 'keeps the "%s" abort the Wizard program matches', (reason) => { diff --git a/scripts/lib/tests/skill-generator-doc-filename.test.js b/scripts/lib/tests/skill-generator-doc-filename.test.js index 583f214e9..3fc0e1437 100644 --- a/scripts/lib/tests/skill-generator-doc-filename.test.js +++ b/scripts/lib/tests/skill-generator-doc-filename.test.js @@ -29,21 +29,16 @@ describe('generateSkill doc filename', () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it('writes a fetched doc under the filename its entry names', async () => { + async function generate(docsUrls) { const config = { 'sending-email': { type: 'skill', template: 'description.md', - variants: [{ - id: 'all', - display_name: 'Sending email', - docs_urls: [{ url: UPSTREAM_SKILL_URL, filename: 'upstream-skill.md', title: 'The upstream skill' }], - }], + variants: [{ id: 'all', display_name: 'Sending email', docs_urls: docsUrls }], }, }; const skill = expandSkillGroups(config, tmpDir)[0]; const outputDir = join(tmpDir, 'out'); - await generateSkill({ skill, version: 'test', @@ -55,12 +50,35 @@ describe('generateSkill doc filename', () => { skillTemplate: skill._template, sharedDocs: skill._sharedDocs || [], }); + return join(outputDir, 'sending-email'); + } + + it('writes a fetched doc under the filename its entry names', async () => { + const skillDir = await generate([ + { url: UPSTREAM_SKILL_URL, filename: 'upstream-skill.md', title: 'The upstream skill' }, + ]); - const references = join(outputDir, 'sending-email', 'references'); + const references = join(skillDir, 'references'); expect(readFileSync(join(references, 'upstream-skill.md'), 'utf8')).toBe('# Upstream skill\n\nSteps'); expect(existsSync(join(references, 'SKILL.md'))).toBe(false); - expect(readFileSync(join(outputDir, 'sending-email', 'SKILL.md'), 'utf8')).toContain( + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain( '- `references/upstream-skill.md` - The upstream skill', ); }); + + it.each(['nested/upstream-skill.md', '../upstream-skill.md', 'upstream-skill.txt', 'COMMANDMENTS.md'])( + 'refuses the filename %s', + async (filename) => { + await expect(generate([{ url: UPSTREAM_SKILL_URL, filename }])).rejects.toThrow(filename); + }, + ); + + it('refuses a filename another reference already uses', async () => { + await expect( + generate([ + { url: UPSTREAM_SKILL_URL, filename: 'upstream-skill.md' }, + { url: 'https://docs.example.test/other.md', filename: 'upstream-skill.md' }, + ]), + ).rejects.toThrow('upstream-skill.md'); + }); }); From 7b7dff3df66c507b54c6ed7ec6669701d2c88a92 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:18:48 +0000 Subject: [PATCH 03/10] fix(skills): refuse a doc whose URL name takes a filename an entry chose The filename check covered only entries that name a filename, so a later doc whose name comes from its URL could still overwrite one. Now no doc may take a filename an entry chose. Docs that both take their names from their URLs keep today's behaviour. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/skill-generator.js | 19 ++++++++++++------- .../skill-generator-doc-filename.test.js | 9 +++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 4d08346c9..91eea2549 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -720,14 +720,19 @@ async function generateSkill({ } // A doc entry's `filename` must be a plain `.md` name that no other - // reference uses, or the fetched doc would overwrite it. - function checkDocFilename(filename, url) { - const taken = filename === 'COMMANDMENTS.md' || references.some((ref) => ref.filename === filename); - if (path.basename(filename) !== filename || !filename.endsWith('.md') || taken) { + // reference uses, and no later doc may take a name an entry chose, or + // one doc would overwrite the other. + const chosenFilenames = new Set(); + function checkDocFilename(filename, url, chosen) { + const taken = references.some((ref) => ref.filename === filename); + const clash = chosen ? taken || filename === 'COMMANDMENTS.md' : chosenFilenames.has(filename); + const malformed = chosen && (path.basename(filename) !== filename || !filename.endsWith('.md')); + if (clash || malformed) { throw new Error( - `${skill.id}: doc ${url} names filename "${filename}"; use a plain .md name no other reference uses` + `${skill.id}: doc ${url} uses filename "${filename}"; a filename must be a plain .md name no other reference uses` ); } + if (chosen) chosenFilenames.add(filename); } // Helper to process a doc entry (string URL or {url, title, filename} object). @@ -738,11 +743,11 @@ async function generateSkill({ const titleOverride = typeof docEntry === 'object' ? docEntry.title : null; const filenameOverride = typeof docEntry === 'object' ? docEntry.filename : null; - if (filenameOverride) checkDocFilename(filenameOverride, url); + const filename = filenameOverride || urlToFilename(url); + checkDocFilename(filename, url, Boolean(filenameOverride)); const result = await fetchDoc(url); if (result) { - const filename = filenameOverride || urlToFilename(url); fs.writeFileSync( path.join(referencesDir, filename), result.content, diff --git a/scripts/lib/tests/skill-generator-doc-filename.test.js b/scripts/lib/tests/skill-generator-doc-filename.test.js index 3fc0e1437..6007a84de 100644 --- a/scripts/lib/tests/skill-generator-doc-filename.test.js +++ b/scripts/lib/tests/skill-generator-doc-filename.test.js @@ -81,4 +81,13 @@ describe('generateSkill doc filename', () => { ]), ).rejects.toThrow('upstream-skill.md'); }); + + it('refuses a doc whose name from its URL another reference already uses', async () => { + await expect( + generate([ + { url: UPSTREAM_SKILL_URL, filename: 'upstream.md' }, + 'https://docs.example.test/other/upstream.md', + ]), + ).rejects.toThrow('upstream.md'); + }); }); From e80d2ac2a87d98acb22afe9dbde8716db6c33aaa Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:16:58 +0000 Subject: [PATCH 04/10] feat(skills): keep the Wizard's transactional email in code by default The Wizard section now asks once, with wizard_ask, "Keep the email in your code (recommended) or design it in PostHog?", with code first. The template steps a missing tool hands to the person apply only when the person picks a template. When the test of a code send answers 404 not_found, the project can't send from code yet: the agent says so in one line, moves the email into a PostHog template, and tests again, rather than aborting. The skill description says code is the default. Co-Authored-By: Claude Opus 5.5 --- .../sending-transactional-emails/config.yaml | 2 +- .../sending-transactional-emails/description.md | 13 +++++++------ .../sending-transactional-emails-skill.test.js | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/context/skills/sending-transactional-emails/config.yaml b/context/skills/sending-transactional-emails/config.yaml index abd99cd4d..5ec524585 100644 --- a/context/skills/sending-transactional-emails/config.yaml +++ b/context/skills/sending-transactional-emails/config.yaml @@ -2,7 +2,7 @@ type: skill template: description.md category: workflows -description: Set up PostHog transactional emails, one-off emails that code sends to one person, such as password resets, sign-in codes, magic links, receipts, and invites. Use when asked to send one of those emails with PostHog, or to set up transactional email. It checks for a verified sender, gets a template made transactional in PostHog, hands over the project secret API key, and writes the send with posthog-node, the posthog Python SDK, or the HTTP API. +description: Set up PostHog transactional emails, one-off emails that code sends to one person, such as password resets, sign-in codes, magic links, receipts, and invites. Use when asked to send one of those emails with PostHog, or to set up transactional email. It checks for a verified sender, writes the email in the app's code by default or as a PostHog template when the person picks that, hands over the project secret API key, and writes the send with posthog-node, the posthog Python SDK, or the HTTP API. tags: [workflows] shared_docs: - https://posthog.com/docs/workflows/transactional-emails.md diff --git a/context/skills/sending-transactional-emails/description.md b/context/skills/sending-transactional-emails/description.md index 8c3ee715b..cd9a1786b 100644 --- a/context/skills/sending-transactional-emails/description.md +++ b/context/skills/sending-transactional-emails/description.md @@ -8,16 +8,17 @@ Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes - **First, look for server code.** Before any question or PostHog step, check that the project has server code that can send: a backend route, a server action, a worker or a job. When it has none, emit `[ABORT] no server code found` and stop. - The person ran the Wizard to set up a transactional email, so skip the skill's advice on when to offer it. -- The Wizard has no chat. Ask every question of the skill with `wizard_ask`, never in plain text, and batch the questions of one step into one call. When the person did not say which email to send, ask them, and offer a welcome email after signup as the first choice. -- **When `wizard_ask` fails.** When it errors or isn't available, don't ask in plain text, and don't send anything. Write the send code only when you already know the email, its variable names and the template ID. List every step you skipped in the report as one the person still has to do, and stop. +- The Wizard has no chat. Ask every question of the skill with `wizard_ask`, never in plain text, and batch the questions of one step into one call. In the first call, ask which email to send when the person did not say, and offer a welcome email after signup as the first choice. In the same call, ask the skill's **Code or template** question as "Keep the email in your code (recommended) or design it in PostHog?", with code as the first choice. Ask it once. +- **When `wizard_ask` fails.** When it errors or isn't available, don't ask in plain text, and don't send anything. Keep the email in code. Write the email and its send code only when you already know which email to send. List every step you skipped in the report as one the person still has to do, and stop. - The PostHog tools of the skill run through `posthog_exec`. Check that a tool exists with `search `. -- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off: don't stop at the skill's check that the project can use them. When a step needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off. When a step needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: - **Verified sender.** They set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says. - - **Template**, **No marketing category.** and **Make it transactional.** They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key. - - **Engagement events.** Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. + - Only when the email is a PostHog template: **Template: no marketing category.** and **Template: make it transactional.** They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key. + - Only when the email is a PostHog template: **Template: engagement events.** Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. - **API key.** Tell the person how to create the key, as the skill's **API key.** step says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable. - **Test.** Ask before the skill's test send, and send only to an address the person owns. Send it through the app's new send code, for example with a one-off script that calls it, and never read, print or pass `POSTHOG_PROJECT_SECRET_API_KEY` yourself. When you can't run that code, ask the person to send it and tell you what came back. When they say no, skip it, and write in the report that no send has confirmed the setup. -- When the person says **Transactional** isn't in their PostHog project, or a test send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop. +- **When code sends aren't on yet.** When the test of a code send answers `404` with the code `not_found`, follow the skill's section of that name: tell the person in one line, move the email into a PostHog template with the template steps above, change the send to the template ID, and test again. Write in the report that the email is a PostHog template because the project can't send from code yet. +- When the person says **Transactional** isn't in their PostHog project, or a test of a template send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop. ## Reference files diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js index 85ccf34ec..d75657bf3 100644 --- a/scripts/lib/tests/sending-transactional-emails-skill.test.js +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -44,4 +44,20 @@ describe('sending-transactional-emails skill', () => { expect(DESCRIPTION).toContain(`emit \`[ABORT] ${reason}\` and stop`); }, ); + + it('asks once whether the email stays in code, with code recommended first', () => { + const wizardSection = DESCRIPTION.slice(DESCRIPTION.indexOf('## Inside the PostHog Wizard')); + const question = '"Keep the email in your code (recommended) or design it in PostHog?"'; + expect(wizardSection.split(question)).toHaveLength(2); + expect(wizardSection).toContain('with code as the first choice. Ask it once.'); + }); + + it('moves a code send that answers not_found to a template instead of aborting', () => { + const fallback = DESCRIPTION.split('\n- ').find((rule) => + rule.startsWith('**When code sends aren\'t on yet.**'), + ); + expect(fallback).toContain('answers `404` with the code `not_found`'); + expect(fallback).toContain('move the email into a PostHog template'); + expect(fallback).not.toContain('[ABORT]'); + }); }); From 76c38e896f35e55598f5c5182fe7d51d2ce8afa5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:34:23 +0000 Subject: [PATCH 05/10] fix(sending-transactional-emails): follow the upstream skill's template section - The Wizard section names the upstream skill's Design it in PostHog steps, which replace the Template: steps. - The rule for projects without code sends goes. This skill publishes only after workflows-email-from-code reaches every project. - The test send's one-off script takes the address as an argument and is deleted after the test. - A doc filename needs a name before .md. Co-Authored-By: Claude Opus 5.5 --- .../skills/sending-transactional-emails/description.md | 7 +++---- scripts/lib/skill-generator.js | 5 +---- .../lib/tests/sending-transactional-emails-skill.test.js | 9 --------- scripts/lib/tests/skill-generator-doc-filename.test.js | 2 +- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/context/skills/sending-transactional-emails/description.md b/context/skills/sending-transactional-emails/description.md index cd9a1786b..38779c3bb 100644 --- a/context/skills/sending-transactional-emails/description.md +++ b/context/skills/sending-transactional-emails/description.md @@ -13,11 +13,10 @@ Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes - The PostHog tools of the skill run through `posthog_exec`. Check that a tool exists with `search `. - The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off. When a step needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: - **Verified sender.** They set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says. - - Only when the email is a PostHog template: **Template: no marketing category.** and **Template: make it transactional.** They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key. - - Only when the email is a PostHog template: **Template: engagement events.** Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. + - Only when the email is a PostHog template: **No marketing category.** and **Make it transactional.**, from the skill's **Design it in PostHog** section. They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key. + - Only when the email is a PostHog template: **Engagement events.** Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. - **API key.** Tell the person how to create the key, as the skill's **API key.** step says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable. -- **Test.** Ask before the skill's test send, and send only to an address the person owns. Send it through the app's new send code, for example with a one-off script that calls it, and never read, print or pass `POSTHOG_PROJECT_SECRET_API_KEY` yourself. When you can't run that code, ask the person to send it and tell you what came back. When they say no, skip it, and write in the report that no send has confirmed the setup. -- **When code sends aren't on yet.** When the test of a code send answers `404` with the code `not_found`, follow the skill's section of that name: tell the person in one line, move the email into a PostHog template with the template steps above, change the send to the template ID, and test again. Write in the report that the email is a PostHog template because the project can't send from code yet. +- **Test.** Ask before the skill's test send, and send only to an address the person owns. Send it through the app's new send code, for example with a one-off script that calls it and takes the address as an argument. Delete the script after the test. Never read, print or pass `POSTHOG_PROJECT_SECRET_API_KEY` yourself. When you can't run that code, ask the person to send it and tell you what came back. When they say no, skip it, and write in the report that no send has confirmed the setup. - When the person says **Transactional** isn't in their PostHog project, or a test of a template send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop. ## Reference files diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 91eea2549..f826aeec8 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -719,14 +719,11 @@ async function generateSkill({ } } - // A doc entry's `filename` must be a plain `.md` name that no other - // reference uses, and no later doc may take a name an entry chose, or - // one doc would overwrite the other. const chosenFilenames = new Set(); function checkDocFilename(filename, url, chosen) { const taken = references.some((ref) => ref.filename === filename); const clash = chosen ? taken || filename === 'COMMANDMENTS.md' : chosenFilenames.has(filename); - const malformed = chosen && (path.basename(filename) !== filename || !filename.endsWith('.md')); + const malformed = chosen && !/^[^/\\]+\.md$/.test(filename); if (clash || malformed) { throw new Error( `${skill.id}: doc ${url} uses filename "${filename}"; a filename must be a plain .md name no other reference uses` diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js index d75657bf3..c60d9e63b 100644 --- a/scripts/lib/tests/sending-transactional-emails-skill.test.js +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -51,13 +51,4 @@ describe('sending-transactional-emails skill', () => { expect(wizardSection.split(question)).toHaveLength(2); expect(wizardSection).toContain('with code as the first choice. Ask it once.'); }); - - it('moves a code send that answers not_found to a template instead of aborting', () => { - const fallback = DESCRIPTION.split('\n- ').find((rule) => - rule.startsWith('**When code sends aren\'t on yet.**'), - ); - expect(fallback).toContain('answers `404` with the code `not_found`'); - expect(fallback).toContain('move the email into a PostHog template'); - expect(fallback).not.toContain('[ABORT]'); - }); }); diff --git a/scripts/lib/tests/skill-generator-doc-filename.test.js b/scripts/lib/tests/skill-generator-doc-filename.test.js index 6007a84de..dc47a7b35 100644 --- a/scripts/lib/tests/skill-generator-doc-filename.test.js +++ b/scripts/lib/tests/skill-generator-doc-filename.test.js @@ -66,7 +66,7 @@ describe('generateSkill doc filename', () => { ); }); - it.each(['nested/upstream-skill.md', '../upstream-skill.md', 'upstream-skill.txt', 'COMMANDMENTS.md'])( + it.each(['nested/upstream-skill.md', '../upstream-skill.md', 'upstream-skill.txt', 'COMMANDMENTS.md', '.md'])( 'refuses the filename %s', async (filename) => { await expect(generate([{ url: UPSTREAM_SKILL_URL, filename }])).rejects.toThrow(filename); From fc7d606c432741ac0cb7cbfbbd9573d8bf6652b9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:57:24 +0000 Subject: [PATCH 06/10] feat(sending-transactional-emails): publish the SDK reference for all eight SDKs PostHog/posthog#128870 adds references/sdks.md, which covers each SDK's client setup, sends and status read. The build fetches it with the skill's other files, and the description names all eight SDKs. The build answers 404 for it until #128870 merges into master. Co-Authored-By: Claude Opus 5.5 --- context/skills/sending-transactional-emails/config.yaml | 4 +++- .../lib/tests/sending-transactional-emails-skill.test.js | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/context/skills/sending-transactional-emails/config.yaml b/context/skills/sending-transactional-emails/config.yaml index 5ec524585..a1ac728b5 100644 --- a/context/skills/sending-transactional-emails/config.yaml +++ b/context/skills/sending-transactional-emails/config.yaml @@ -2,7 +2,7 @@ type: skill template: description.md category: workflows -description: Set up PostHog transactional emails, one-off emails that code sends to one person, such as password resets, sign-in codes, magic links, receipts, and invites. Use when asked to send one of those emails with PostHog, or to set up transactional email. It checks for a verified sender, writes the email in the app's code by default or as a PostHog template when the person picks that, hands over the project secret API key, and writes the send with posthog-node, the posthog Python SDK, or the HTTP API. +description: Set up PostHog transactional emails, one-off emails that code sends to one person, such as password resets, sign-in codes, magic links, receipts, and invites. Use when asked to send one of those emails with PostHog, or to set up transactional email. It checks for a verified sender, writes the email in the app's code by default or as a PostHog template when the person picks that, hands over the project secret API key, and writes the send with PostHog's SDK for the backend's language (Node.js, Python, Go, Ruby, PHP, .NET, Rust or Elixir) or the HTTP API. tags: [workflows] shared_docs: - https://posthog.com/docs/workflows/transactional-emails.md @@ -16,3 +16,5 @@ variants: title: The sending-transactional-emails skill. Read it first - url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/references/moving-from-other-providers.md title: Moving a send from another provider to PostHog + - url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/references/sdks.md + title: Each PostHog SDK's client setup, sends and status read diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js index c60d9e63b..4915c48e1 100644 --- a/scripts/lib/tests/sending-transactional-emails-skill.test.js +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -31,6 +31,14 @@ describe('sending-transactional-emails skill', () => { expect(DESCRIPTION).toContain('Read `references/sending-transactional-emails.md` first'); }); + it('fetches every reference file the PostHog skill links to', () => { + const fetched = skill()[0].docs_urls.map((doc) => doc.url.split('/sending-transactional-emails/')[1]); + + expect(fetched).toEqual( + expect.arrayContaining(['references/moving-from-other-providers.md', 'references/sdks.md']), + ); + }); + it('checks for server code before it asks anything', () => { const wizardSection = DESCRIPTION.slice(DESCRIPTION.indexOf('## Inside the PostHog Wizard')); const firstRule = wizardSection.split('\n- ')[1]; From 3390e970cc5c028af0a074aa2a46a75610a850af Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:40:11 +0000 Subject: [PATCH 07/10] fix(sending-transactional-emails): ask about engagement events for every send PostHog records engagement events for code sends only when the project's setting is on, as for template sends. The Wizard section asked about the setting only for a template. Co-Authored-By: Claude Opus 5.5 --- context/skills/sending-transactional-emails/description.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/sending-transactional-emails/description.md b/context/skills/sending-transactional-emails/description.md index 38779c3bb..5ad0b5130 100644 --- a/context/skills/sending-transactional-emails/description.md +++ b/context/skills/sending-transactional-emails/description.md @@ -14,7 +14,7 @@ Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes - The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean transactional emails are off. When a step needs a tool that is missing, ask the person to do that step in PostHog, and wait until they confirm it is done: - **Verified sender.** They set one up as [configure a channel](https://posthog.com/docs/workflows/configure-channels) says. - Only when the email is a PostHog template: **No marketing category.** and **Make it transactional.**, from the skill's **Design it in PostHog** section. They open **Transactional** in PostHog, click **New transactional email**, choose the verified sender, and write the subject and body. Agree on the variable names first, and give them to the person as `{{ variables. }}`. The email can't be in a marketing message category. For sign-in codes, reset links and other secrets, tell them to turn on **Protect sensitive data** on the email's **API** tab. Then ask for the template ID, which that tab shows, in the same `wizard_ask` call as the key. - - Only when the email is a PostHog template: **Engagement events.** Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. + - **Engagement events.**, for code and template sends alike. Tell them PostHog records no send, delivery, open, click or bounce events on the person until they turn on **Engagement events** in project settings, under **Workflows**, and that these events count toward the project's event usage. - **API key.** Tell the person how to create the key, as the skill's **API key.** step says. Then ask for it with `wizard_ask` and `sensitive: true`, and pass the answer to `set_env_values` as `POSTHOG_PROJECT_SECRET_API_KEY`. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. The code reads the key from that variable. - **Test.** Ask before the skill's test send, and send only to an address the person owns. Send it through the app's new send code, for example with a one-off script that calls it and takes the address as an argument. Delete the script after the test. Never read, print or pass `POSTHOG_PROJECT_SECRET_API_KEY` yourself. When you can't run that code, ask the person to send it and tell you what came back. When they say no, skip it, and write in the report that no send has confirmed the setup. - When the person says **Transactional** isn't in their PostHog project, or a test of a template send answers `404` with the code `not_found` for a template ID the person checked on its **API** tab, emit `[ABORT] transactional email api not enabled` and stop. From 97c703d78c8257d501865d82257455c832f8a3a1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:04:17 +0000 Subject: [PATCH 08/10] fix(skills): title sending-transactional-emails in the skill menu The menu's new title field comes from the variant's display_name, which read 'all supported platforms'. Co-Authored-By: Claude Opus 5.5 --- context/skills/sending-transactional-emails/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/skills/sending-transactional-emails/config.yaml b/context/skills/sending-transactional-emails/config.yaml index a1ac728b5..d57b8d6e5 100644 --- a/context/skills/sending-transactional-emails/config.yaml +++ b/context/skills/sending-transactional-emails/config.yaml @@ -9,7 +9,7 @@ shared_docs: - https://posthog.com/docs/workflows/transactional-emails/api.md variants: - id: all - display_name: all supported platforms + display_name: Transactional Emails docs_urls: - url: https://raw.githubusercontent.com/PostHog/posthog/master/products/messaging/skills/sending-transactional-emails/SKILL.md filename: sending-transactional-emails.md From 6e675d187e39250a9afffc7e22d778014cc5cb92 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:20:45 +0000 Subject: [PATCH 09/10] test(skills): check the built sending-transactional-emails package, not its source text Build the skill with fetch stubbed and assert what the package holds: the PostHog skill as a reference, no dangling reference, and exactly the [ABORT] reasons the Wizard program matches. Drop the tests that pinned the wording and order of the Wizard section. Co-Authored-By: Claude Opus 5.5 --- ...sending-transactional-emails-skill.test.js | 107 ++++++++++-------- 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js index 4915c48e1..4a5c7dc07 100644 --- a/scripts/lib/tests/sending-transactional-emails-skill.test.js +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -1,62 +1,79 @@ /** - * The Wizard's `transactional-email` program runs this skill by its ID and - * matches the `[ABORT]` reasons of its Wizard section, so both are pinned here. + * The Wizard's `transactional-email` program installs this skill by its ID and + * matches the `[ABORT]` reasons its built SKILL.md tells the agent to emit. */ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; +import { tmpdir } from 'os'; -import { expandSkillGroups, loadSkillsConfig } from '../skill-generator.js'; +vi.hoisted(() => { + process.env.DOCS_CACHE_TTL_MS = '0'; +}); + +import { expandSkillGroups, generateSkill, loadSkillsConfig } from '../skill-generator.js'; const CONFIG_DIR = join(process.cwd(), 'context'); -const DESCRIPTION = readFileSync( - join(CONFIG_DIR, 'skills', 'sending-transactional-emails', 'description.md'), - 'utf8', -); - -function skill() { - const config = loadSkillsConfig(CONFIG_DIR); - return expandSkillGroups(config, CONFIG_DIR).filter( - (s) => s._group === 'sending-transactional-emails', - ); -} - -describe('sending-transactional-emails skill', () => { - it('expands to the one skill ID the Wizard program runs', () => { - expect(skill().map((s) => s.id)).toEqual(['sending-transactional-emails']); +const UPSTREAM_SKILL = '---\nname: sending-transactional-emails\n---\n\n# Upstream skill\n'; + +describe('the built sending-transactional-emails skill', () => { + let outputDir; + + beforeEach(() => { + outputDir = mkdtempSync(join(tmpdir(), 'sending-transactional-emails-')); + vi.stubGlobal( + 'fetch', + vi.fn(async (url) => + new Response(String(url).endsWith('/sending-transactional-emails/SKILL.md') ? UPSTREAM_SKILL : `# ${url}\n`), + ), + ); }); - it('reads the PostHog skill under the name its description points to', () => { - expect(skill()[0].docs_urls[0].filename).toBe('sending-transactional-emails.md'); - expect(DESCRIPTION).toContain('Read `references/sending-transactional-emails.md` first'); + afterEach(() => { + vi.unstubAllGlobals(); + rmSync(outputDir, { recursive: true, force: true }); }); - it('fetches every reference file the PostHog skill links to', () => { - const fetched = skill()[0].docs_urls.map((doc) => doc.url.split('/sending-transactional-emails/')[1]); + async function build() { + const config = loadSkillsConfig(CONFIG_DIR); + const [skill] = expandSkillGroups(config, CONFIG_DIR).filter((s) => s._group === 'sending-transactional-emails'); + await generateSkill({ + skill, + version: 'test', + repoRoot: process.cwd(), + configDir: CONFIG_DIR, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: skill._template, + sharedDocs: skill._sharedDocs || [], + }); + return join(outputDir, 'sending-transactional-emails'); + } - expect(fetched).toEqual( - expect.arrayContaining(['references/moving-from-other-providers.md', 'references/sdks.md']), - ); + it('packages the PostHog skill as a reference, not as a second SKILL.md', async () => { + const skillDir = await build(); + + expect(readFileSync(join(skillDir, 'references', 'sending-transactional-emails.md'), 'utf8')).toBe(UPSTREAM_SKILL); + expect(existsSync(join(skillDir, 'references', 'SKILL.md'))).toBe(false); }); - it('checks for server code before it asks anything', () => { - const wizardSection = DESCRIPTION.slice(DESCRIPTION.indexOf('## Inside the PostHog Wizard')); - const firstRule = wizardSection.split('\n- ')[1]; - expect(firstRule).toContain('emit `[ABORT] no server code found` and stop'); - expect(firstRule).not.toContain('wizard_ask'); + it('names only reference files the package holds', async () => { + const skillDir = await build(); + const named = readFileSync(join(skillDir, 'SKILL.md'), 'utf8').match(/references\/[\w.-]+\.md/g); + + expect(named).toContain('references/sending-transactional-emails.md'); + for (const reference of new Set(named)) { + expect(existsSync(join(skillDir, reference)), reference).toBe(true); + } }); - it.each(['transactional email api not enabled', 'no server code found'])( - 'keeps the "%s" abort the Wizard program matches', - (reason) => { - expect(DESCRIPTION).toContain(`emit \`[ABORT] ${reason}\` and stop`); - }, - ); - - it('asks once whether the email stays in code, with code recommended first', () => { - const wizardSection = DESCRIPTION.slice(DESCRIPTION.indexOf('## Inside the PostHog Wizard')); - const question = '"Keep the email in your code (recommended) or design it in PostHog?"'; - expect(wizardSection.split(question)).toHaveLength(2); - expect(wizardSection).toContain('with code as the first choice. Ask it once.'); + it('emits only the [ABORT] reasons the Wizard program matches', async () => { + const skillDir = await build(); + const reasons = [...readFileSync(join(skillDir, 'SKILL.md'), 'utf8').matchAll(/\[ABORT\] ([^`\n]+)`/g)].map( + (match) => match[1], + ); + + expect(new Set(reasons)).toEqual(new Set(['no server code found', 'transactional email api not enabled'])); }); }); From b58090a28dc3d436b942c0f3f99d0c702d9f1073 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:51:16 +0000 Subject: [PATCH 10/10] fix(skills): name the transactional emails docs by their reference path The test that checks every named reference file now covers both docs. Its [ABORT] pattern no longer needs a closing backtick, and the header comment goes. Co-Authored-By: Claude Opus 5.5 --- context/skills/sending-transactional-emails/description.md | 2 +- .../lib/tests/sending-transactional-emails-skill.test.js | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/context/skills/sending-transactional-emails/description.md b/context/skills/sending-transactional-emails/description.md index 5ad0b5130..13824333d 100644 --- a/context/skills/sending-transactional-emails/description.md +++ b/context/skills/sending-transactional-emails/description.md @@ -1,6 +1,6 @@ # Sending transactional emails -This skill publishes PostHog's `sending-transactional-emails` skill, which PostHog keeps next to its product code. Read `references/sending-transactional-emails.md` first, and follow it. Where it links to `references/`, the file is in this skill's `references/` folder. The transactional emails docs are in the same folder: `transactional-emails.md` has every error code, and `api.md` has the HTTP send. +This skill publishes PostHog's `sending-transactional-emails` skill, which PostHog keeps next to its product code. Read `references/sending-transactional-emails.md` first, and follow it. Where it links to `references/`, the file is in this skill's `references/` folder. The transactional emails docs are in the same folder: `references/transactional-emails.md` has every error code, and `references/api.md` has the HTTP send. ## Inside the PostHog Wizard diff --git a/scripts/lib/tests/sending-transactional-emails-skill.test.js b/scripts/lib/tests/sending-transactional-emails-skill.test.js index 4a5c7dc07..bdc989de0 100644 --- a/scripts/lib/tests/sending-transactional-emails-skill.test.js +++ b/scripts/lib/tests/sending-transactional-emails-skill.test.js @@ -1,7 +1,3 @@ -/** - * The Wizard's `transactional-email` program installs this skill by its ID and - * matches the `[ABORT]` reasons its built SKILL.md tells the agent to emit. - */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; @@ -70,7 +66,7 @@ describe('the built sending-transactional-emails skill', () => { it('emits only the [ABORT] reasons the Wizard program matches', async () => { const skillDir = await build(); - const reasons = [...readFileSync(join(skillDir, 'SKILL.md'), 'utf8').matchAll(/\[ABORT\] ([^`\n]+)`/g)].map( + const reasons = [...readFileSync(join(skillDir, 'SKILL.md'), 'utf8').matchAll(/\[ABORT\] ([a-z][a-z ]*[a-z])/g)].map( (match) => match[1], );