Skip to content

Code-managed templates: the change prompt on the template API #512

Closed

Description

@Silthus

Map: Map: send code-managed email templates with one PostHog call
Spec: Spec: copy-as-code and open-a-PR for code-managed templates (revision 3), section "The change prompt (one builder, on the server)"

Cast: Opus 5.5, high
Repository: PostHog/posthog (backend)
Stack: A, first slice, on top of PostHog#118924

Question

Does the template API give every caller (the web page, PostHog AI, the Slack bot, any MCP client, and the PostHog Code task) the same "About this file" context for a code-managed template?

Delivers end to end.

  • Builder. One Python builder in the messaging product (service, reached through the facade) turns a saved template into the spec's "About this file" block:
    • key, repository and short ref;
    • the file contract;
    • {{ variables.* }} only, with the variables found in the published subject, preheader and HTML, de-duplicated and in order of first use;
    • how the change goes live, for a file reference and for a send by key;
    • the absolute template link.
    • Each line is omitted when its value is unknown. Without a path, the line that finds the file by key replaces the file line.
  • Starter. The same builder makes the starter: In `<path>`, change or In the HTML file of the `<key>` email template, change .
  • Serializer. The template serializer returns two read-only fields, change_prompt_context and change_prompt_starter, with help_text. They are null unless the template is code-managed.
  • Regenerate OpenAPI and the frontend types. The MCP retrieve tool returns both fields.

Test seams.

  • Django APIBaseTest on retrieve, for:
    • a GUI template (nulls);
    • a code-managed template with full source;
    • path only;
    • no source;
    • repeated variables and none;
    • Liquid other than variables, which is not listed.
  • Assert the exact text.
  • Prior art: the message template API tests in products/messaging/backend/.

Write scope.

  • products/messaging/backend/: a new service module, facade/templates.py, the template serializer in presentation/views/message_templates.py, tests.
  • Generated types.
  • products/workflows/mcp/email_templates.yaml, if the retrieve tool's response include list needs the fields.

Proof.

  • Red: the "full source" text test fails.
  • Green: the API tests pass.
  • Local: upload a template through posthog-node, GET it, and paste the two fields in the PR.

Acceptance criteria

  • A code-managed template returns the block and the starter; a GUI template returns nulls.
  • The block matches the spec line for line.

Blockers

None. Starts from the integration branch of PostHog#118924.

How to build (map #461)

  • /tdd red-green-refactor, outside-in, at the seams named above. Real Postgres, Valkey and ClickHouse; mock only what the seam section allows.
  • Repo skills apply where the diff touches their area: /writing-tests, /writing-ui-components, /writing-kea-logics, /writing-user-facing-copy, /writing-code-comments, /improving-drf-endpoints, /routing-outbound-api-calls.
  • Read code from origin/spec/code-managed-templates (the fork's integration branch for feat(messaging): upload, send and lock code-managed email templates PostHog/posthog#118924), not from master.
  • Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or the PR. Fixture addresses use example.com. The private note the map points at is background only; never quote it.
  • Shipped means a draft PR handed off for review (see the map's Notes for remotes and branches). Never mark ready, enqueue or merge. The conductor names the stack's integration branch.

Activity

  1. self-assigned this
    on Oct 17, 2026
  2. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Runtime: T3 child task
    Task: node:delegated-task:command%3Amcp%3A1778d880-5a25-404c-80d0-25fc5ccca3b2%3Adelegate-task%3Awf-email-impl-512-1
    Cast: Opus 5.5, high. Cap 180 minutes.

  3. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: 0ba8f7972c2 on spec/template-agent-edit (fast-forward 07d7e0ee544..0ba8f7972c2, two commits).
    Branch: spec/template-agent-edit/512. No PR: the ship ticket #516 opens it.

    What landed

    • Builder (products/messaging/backend/services/template_change_prompt.py), reached through facade/templates.py as change_prompt(team_id, template). It returns a TemplateChangePrompt(starter, context) (new in facade/contracts.py) for a template managed by code, and None for any other.
    • "About this file" block, line for line as spec Spec: copy-as-code and open-a-PR for code-managed templates #502 writes it:
      • key, repository and ref. A commit SHA is shortened to 7 characters;
      • the HTML file contract;
      • {{ variables.* }} only, with the variables of the published subject, preheader and HTML, de-duplicated and in order of first use. With none, the line ends after "posthog.emails.send.";
      • how the change goes live, for a file reference and for a send by key;
      • the absolute template link (SITE_URL/project/<team>/workflows/library/templates/<id>).
    • Without a path, the first line becomes "Find the HTML file that the app loads with posthog.emails.templateFile() for the key <key>", and the starter becomes In the HTML file of the `<key>` email template, change .
    • Serializer: MessageTemplateSerializer returns the read-only fields change_prompt_starter and change_prompt_context, with help_text. Both are null unless managed_by is code.
    • Generated: hogli build:openapi updated products/messaging/frontend/generated/api.schemas.ts and the MCP client. workflows-get-email-template returns both fields, with no YAML change, because its response uses an exclude list. workflows-list-email-templates excludes them, because that tool is documented as metadata only.

    Proof

    Red: the "full source" test, run before the change:

    E       KeyError: 'change_prompt_starter'
    products/messaging/backend/tests/api/test_template_change_prompt.py:68: KeyError
    1 failed in 13.99s
    

    Green: test_template_change_prompt.py has 7 passed. The whole products/messaging/backend suite has 384 passed.

    Seam: APIBaseTest, upload by key, then GET Asserts
    full source (repository, path, 40-character SHA) exact block: commit shortened to 3f2c1ab, variables across subject, preheader and HTML
    path only exact block and the path starter
    no source the "Find the HTML file" line and the key starter
    repository and branch, no path "Find …" line keeps the repository; a branch ref is not called a commit
    repeated variables, {{x}} without spaces, a filter, {% if %} each listed once, in order of first use
    no variables the line ends after posthog.emails.send.
    Liquid other than variables (person.properties, event.properties.variables.x, plain text) only variables.reset_url listed
    released template (keyed, managed_by: gui) both fields null

    Mutation: dropping the managed_by check from the builder fails the released-template test (1 failed, 6 passed).

    Local gate on the landed tree:

    • uv run mypy --cache-fine-grained . through fleet heavy: Success: no issues found in 24123 source files.
    • tsgo --noEmit --checkers 1 in frontend/ through fleet heavy: exit 0.
    • pnpm --filter=@posthog/mcp run typecheck: exit 0. lint-tool-names: pass. tool-schema-snapshots and generate-tools: 138 passed.
    • hogli build:openapi: exit 0, output committed.
    • The pre-push hook (hogli ci:preflight --strict): "failures": 0.

    Local proof. Django from this worktree on a scratch database cloned from a migrated test database, with a verified default sender and only workflows-transactional-email-api forced on. posthog-node from PostHog/posthog-js#5312's head (5a896e92) uploaded a file emails/welcome.html through posthog.emails.uploadTemplate(templateFile(...), { source: { repository, ref } }). The path came from the SDK. The repository and ref were passed explicitly, because #510's detection is not on that head.

    Upload result and GET
    $ node upload.mjs
    {
      "data": {
        "id": "01a148c7-7912-0000-f7e3-d9ac5081d0c6",
        "key": "welcome",
        "result": "created",
        "contentHash": "v1:8cd82b332602582d4d794fcfac35d56a8dcd0d9d314fcbe4721ad66f3932905f"
      },
      "error": null
    }
    
    $ curl http://127.0.0.1:8512/api/projects/9/messaging_templates/01a148c7-7912-0000-f7e3-d9ac5081d0c6/ -H 'Authorization: Bearer phx_…'
    {'key': 'welcome', 'managed_by': 'code', 'source_repository': 'github.com/example/app', 'source_path': 'emails/welcome.html', 'source_ref': '9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d'}
    change_prompt_starter: 'In `emails/welcome.html`, change '
    change_prompt_context:
    About this file:
    - It is the PostHog email template with the key `welcome`, in the repository `github.com/example/app`, last uploaded from commit `9e8d7c6`.
    - The file is the whole email. The subject is the first `<title>`. The inbox preview text is the `content` of `<meta name="preheader">`. Everything in the file is the HTML that PostHog sends.
    - Personalize only with `{{ variables.<name> }}`. The values come from the `data` the app passes to `posthog.emails.send`. This file uses `{{ variables.first_name }}` and `{{ variables.confirm_url }}`.
    - Do not upload the file by hand. If the app sends it with `posthog.emails.templateFile()`, the next send uploads the changed file. If the app sends it by the key `welcome`, upload the file again after the change (PUT /api/projects/@current/messaging_templates/by_key/welcome/ or the PostHog MCP upload tool).
    - The template in PostHog: http://localhost:8010/project/9/workflows/library/templates/01a148c7-7912-0000-f7e3-d9ac5081d0c6
    

    The file also used {{ person.properties.email }}. As the spec says, it is not listed.

    Calls made

    • A known repository survives a missing path. The spec's path-less first line has no repository or ref. When the template records them, the line keeps them: "Find the HTML file … for the key welcome, in the repository github.com/example/app, last uploaded from release/october." Without a source, the line is the spec's text exactly. This serves user story 9 (the right file in a monorepo or a workspace with several repositories).
    • A ref that is not a commit SHA (^[0-9a-f]{7,40}$, the frontend's isCommitSha rule) shows as last uploaded from `main`, not as a commit, and is not shortened.
    • The variable scan reads Liquid markup only ({{ … }} and {% … %}). {% if variables.plan %} counts as use. variables.x in plain text and event.properties.variables.x don't.
    • Null without a key too. Every code-managed template has a key from its upload, so this changes nothing today. It keeps the block from naming no template.
    • Test seams as the ticket named them: APIBaseTest on retrieve, templates created by the real upload route. The GUI case is a released template (keyed, gui). A template created in PostHog has no key, so it would pass even without the ownership check. The mutation run shows the released case catches that.
    • List responses carry the fields too, because list and retrieve share the serializer. The builder is pure, with no query per row.
    • Boy scout: the view's _with_creators became _serializable, because it now adds the prompt fields as well as the creator.
    • No review ran in this ticket. The ship ticket Ship stack A: edit a code-managed template with your agent #516 owns review.

    Follow-ups

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions