Skip to content

feat(workflows): edit a code-managed email template with your agent - #118980

Closed
Silthus wants to merge 36 commits into
PostHog:masterfrom
Silthus:spec/template-agent-edit
Closed

Silthus wants to merge 36 commits into
PostHog:masterfrom
Silthus:spec/template-agent-edit

Conversation

@Silthus

@Silthus Silthus commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Important

Stacks on #118924 (spec/code-managed-templates). GitHub can't base a PR on a fork branch, so this targets master. Review starts after the merged tip of spec/code-managed-templates, d746c355e26: git diff d746c355e26...spec/template-agent-edit.

Problem

A person who sees a code-managed email template in PostHog has no path back to its file.
The page is read-only (#118924). Duplicate makes an unkeyed copy that nothing sends, and "Stop managing in code" lets the file and PostHog drift apart.
The change belongs in the repository, and today the person or their coding agent must work out which file it is, what format PostHog reads, and how the change goes live.

Warning

🟡 Merge risk: medium · two-way door (merge-risk rubric)

  • The template API's list and retrieve responses gain three read-only fields, also in the MCP get tool. Every template response runs the prompt builder, and agents act on the text it returns.
  • Limits: the fields are null unless managed_by is code, which only the flagged upload sets. The builder is a linear scan with no query per row, and it treats uploaded source values that could carry an instruction as unknown. Nothing writes, and a revert removes it all.

Changes

On a code-managed template, the Save slot now holds Edit with your agent. Only the flagged upload (workflows-transactional-email-api) makes a template code-managed, so nobody sees it today.

  • The popover has one "Describe the change" text area, prefilled with a starter such as In `emails/welcome.html`, change . "Copy prompt" stays disabled until the person describes a change. "Show full prompt" shows what gets copied.
  • The copied prompt is the person's sentence, a blank line, and an "About this file" block: the key, repository and commit, the HTML file contract, the {{ variables.* }} the file uses, how the change goes live, and a link back.
  • The server builds the block. MessageTemplateSerializer returns change_prompt_starter, change_prompt_context and change_prompt_variables, all null unless managed_by is code. The MCP get tool returns them, so an agent in the repository reads the same text.
  • Uploaded source values stay out of the prompt when they could carry an instruction. A repository, path or ref with whitespace, backticks or control characters counts as unknown.
  • Copy HTML file, in the button's side menu, turns the published subject, preheader and HTML into the file posthog.emails.templateFile() reads. A file the SDK uploaded copies back byte for byte. A template with a plain-text part gets a toast note that the file drops it.
  • Nothing is saved. Neither copy sends a write request. The template, its hash and the lock stay unchanged.
  • The notice now says: "This template is managed by code, so it can't be changed here. To change it, edit its file, or let your coding agent do it."
  • Events: message template agent prompt opened, … agent prompt copied, … html file copied, … duplicated (page, "Duplicate with my changes" and library card), … released from code. No prompt text, path or repository in properties.

Captures from the real app built from this branch, on a scratch project with invented content (Hedgebox, example.com), at fa25e693a33 and 069216473b7:

Describe the change, Copy prompt, full prompt (1440px) Same at 560px, about the scene width with the side panel open
The popover with a described change, Copy prompt above the expanded full prompt The same popover at 560px
After Copy prompt: the new notice and the toast Side menu: Copy HTML file Copy HTML file on a template with a plain-text part
Page after copying the prompt, with the notice and the toast The split button's side menu with Copy HTML file Toast with the plain-text note

Reading order

Ticket Commits What
Silthus/posthog#512 4e143097702, 0ba8f7972c2 Change prompt builder, serializer fields, regenerated types and MCP client
Silthus/posthog#503 62c695af6cb "Edit with your agent" popover, copy prompt, notice, events
Silthus/posthog#509 8a50f6ef11c "Copy HTML file" encoder and side menu
Ship 069216473b7 Merge of spec/code-managed-templates at d746c355e26
Review round 1 4a8f7e1a307, 36868e96ec7, 37b863356f6, aa430103f2f, 5ff207cb20c Source values out of the prompt, linear Liquid scan and change_prompt_variables, encoder edge cases, events, focus, stale sentence
Review round 2 and final reads 01cbef1d39e, fa25e693a33, 158cccc1401, 3ae1cce0091, 004b1bdc322, 0960e3de116 Path shape, focus return, Copy prompt above the full prompt, full prompt paints in Chromium, one-pass preheader removal, templates a formatter rewrote restored, a second click and Escape on the button close the prompt

How did you test this code?

Live run in the real app, built from this branch, on a scratch database, at 069216473b7 and again after the fixes:

  • Upload, then read: posthog-node from feat(node): send transactional email with the flat SDK contract posthog-js#5312 uploaded emails/welcome.html from a scratch git checkout. GET returned the path starter, the full block with the short commit, and change_prompt_variables in order. A REST upload without a path got the path-less starter. A template made in PostHog, a released one, and one written by the base code all read correctly. List and retrieve agree.
  • Injection attempt: a REST upload with a newline in source.repository, backticks and spaces in source.path, and ; in source.ref produced a prompt naming none of them. A route-group path (app/(marketing)/emails/[locale]/welcome.html) stays in the starter.
  • In the browser (Chromium, 1440px and 560px): the text area gets focus with the caret at the end. The clipboard equals the sentence, a blank line and change_prompt_context. The full prompt paints. Focus returns to the button after Escape, Ctrl+Enter and Copy prompt. "Copy HTML file" gives back the SDK's file byte for byte. A two-preheader template and Liquid holding & both read back through the SDK's parseTemplateFile. No write request left the page after any copy.
  • Agent round trip: the copied prompt, pasted into a Claude Code session in the checkout, changed only the button color in the file. A re-upload returned updated with a new content hash.
  • The second-click and Escape-on-button fixes (004b1bdc322, 0960e3de116) and the stricter path shape (3ae1cce0091) came after the last browser run. Their evidence is the render test and the API test below.

Test rationale:

  • test_template_change_prompt.py (APIBaseTest, templates created by the real upload route, then GET): the exact block for full source, path only, no source, a branch ref, a route-group path, source values that could carry an instruction (with and without spaces), repeated and non-variables Liquid, and change_prompt_variables. A released template returns null fields, which catches a builder that ignores managed_by.
  • messageTemplateLogic.test.ts (API mocked at HTTP): the disabled copy, the exact clipboard text, no PATCH or POST on either copy, toasts and event properties, the sentence dropped when a reload points at another file, the duplicate event on "Duplicate with my changes".
  • EditWithAgentButton.test.tsx (rendered, user events): focus returns to the button after Escape (from the text area and from the button), a second click on the button, and Copy prompt. Only the DOM shows this.
  • templateHtmlFile.test.ts: three SDK-uploaded files encode back byte for byte. Ten template shapes, including duplicate preheader tags and Liquid holding &amp; or </title, encode to files that the SDK's recorded parseTemplateFile reads back as the template. A second encode changes nothing.
  • Not committed: the old Liquid regex took 8.4 s on 40k adversarial characters, the forward scan 4 ms on 200k. Removing 8,000 duplicate preheaders takes 32 ms.

Local gate on HEAD: messaging backend 387 passed, mypy clean, frontend tsgo clean, TemplateLibrary + Git jest 9 suites (110 tests) passed, MCP typecheck, tool schema snapshots and tool-name lint, pre-push ci:preflight --strict 0 failures.
Gaps: CI on this fork PR waits for a maintainer to approve the run. Prod shape is not measured until production read access exists (2026-11-02). A real send into the dev mail catcher was skipped: the send path is unchanged and #118924 proved it.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

The docs section on changing a code-managed template ships with PostHog/posthog.com#21306 (Silthus/posthog#507).

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Opus 5.5 wrote the code and the review fixes.

Built as three tickets of spec Silthus/posthog#502 (map Silthus/posthog#461), each test-first with /tdd, then reviewed as one PR with a QA swarm because the diff extends a public API contract. Skills: /improving-drf-endpoints, /implementing-mcp-tools, /writing-ui-components, /writing-kea-logics, /writing-tests, /writing-user-facing-copy, /writing-pr-descriptions. Every screenshot and fixture uses invented data (example.com, MockHog). CodeRabbit is retired on this repository, so it did not run.

Decisions

Question Answer Who Where
How does a person change a code-managed template from PostHog? PostHog hands them a prompt for their coding agent; no editing inside PostHog 👤 Human spec #502, revision 2
Which copy is primary? The agent prompt; "Copy HTML file" is secondary 👤 Human spec #502, revision 2
Where do the repository and path come from? The template's source metadata; without it the prompt and copy still work, with no dialog 👤 Human spec #502, revision 2
Where is the prompt built? Once on the server, returned on the template; no frontend builder, so copy and agents never drift 🤖 Agent spec #502
What does the prompt UI look like? A popover with one text area and a starter, not a modal or a form 🤖 Agent spec #502
What happens to a stored plain-text part? The file never carries one; the copy warns and never blocks 🤖 Agent spec #502
Does a path-less prompt keep a known repository and ref? Yes, the "Find the HTML file" line names them; the spec's line had neither 🤖 Agent #512
How does a branch ref show? As `main`, not as a shortened commit 🤖 Agent #512
What counts as "not described"? Blank, or the starter after trimming both sides 🤖 Agent #503
Does copying show a pending state? No: it is a local clipboard write; failures use copyToClipboard's toasts 🤖 Agent #503
How does the encoder read a file? It mirrors the SDK's head scan, pinned by vectors recorded from PostHog/posthog-js#5312 🤖 Agent #509
How does the encoder keep Liquid intact? Liquid stays literal except character references and </title, which the SDK would decode or stop at; a preheader whose Liquid holds " gets single quotes. Replaced "escaping skips Liquid" 🤖 Agent #516
How does an uploaded source value stay out of the agent's instructions? The builder treats a value outside a repository, path or ref shape as unknown (no whitespace, backticks or shell syntax); upload validation stays as #118924 built it, since the spec puts it out of scope 🤖 Agent #516
How does the page count the prompt's variables? From a third read-only field, change_prompt_variables, instead of rescanning the prompt's prose; two tickets had built the scan twice 🤖 Agent #516
Where does Copy prompt sit? Above "Show full prompt", so the main action stays visible when the prompt expands 🤖 Agent #516
Where does the second-click fix live? In this component: it ignores pointer presses on its own trigger, and still lets Escape close. The shared Popover never registers its trigger, so other toggles such as "Quick start" behave the same; that fix is a follow-up 🤖 Agent #516
Does the button need a frontend flag check? No: only flagged uploads make a template code-managed, as for the base PR's tag and notice 🤖 Agent #516
What is the merge risk? Medium: additive read-only fields on a public API plus a format boundary with the SDK; no shadow diff owed 🤖 Agent #516

Involvement: no grilling or prototype rounds for this stack; QA swarm with 2 panel rounds and 4 final reads (GPT-6.1 Sol high: correctness, risk, design, systems, author perspective; Opus 5.5 high: fresh eyes, UI); 3 live runs captured in the real app.

Closes Silthus#512
Closes Silthus#503
Closes Silthus#509

🤖 Generated with Claude Code

Silthus and others added 25 commits October 16, 2026 18:42
Seven nullable columns on the message template, and a partial unique
index on (team, key) for templates that are not deleted. The index is
built concurrently in its own non-atomic migration. Nothing reads or
writes the columns yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PUT /api/projects/{project_id}/messaging_templates/by_key/{key}/ creates the
template on the first upload (keyed, managed by code, transactional with the
default verified sender), publishes changed content after that, and answers
unchanged when nothing changed. Project secret API keys with hog_flow:write can
call it, behind workflows-transactional-email-api.

The update, design and upload routes now share one save-and-sync service that
writes the template, its content hash and the workflow version together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A template uploaded from code is replaced by its next upload, so an edit
made in PostHog was lost without warning. One ownership policy now
refuses every write to a template with managed_by = code except the
upload, sends, make transactional and a release. It answers 403
immutable with why, fix (naming the file when the source is known) and
docs_url.

The policy keys on the action and the whole payload, so a write action
added later is refused until someone allows it. It runs on the row read
by the permission check, and again on the row locked by the save path,
so an upload that claims the template mid-request still wins.

A PATCH whose only field is managed_by: gui releases the template. It
keeps its key, so the next upload claims it again. The MCP update and
design tool descriptions point to the upload tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntent hash

POST /api/projects/{project_id}/messaging/send/email/ names the template in
the body as {key, hash?} or {id} and sends it through the existing send
service, behind workflows-transactional-email-api. Project secret API keys
with hog_flow:write can call only this action.

A hash the template never published answers 409 template_outdated and sends
nothing. A hash newer content replaced sends the current content with a
template_superseded warning, so an old deploy never brings old content back.
The send usage event records how the template was addressed and how the
hash compared. A generated MCP tool exposes the send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o spec/code-managed-templates/488

# Conflicts:
#	products/messaging/backend/facade/templates.py
#	products/messaging/backend/presentation/views/message_templates.py
A PATCH whose only field is key gives a keyless template its key, once,
so the next upload under that key takes it over and keeps its id,
workflow and sender. Refuses key_already_set, key_in_use,
invalid_template_key, and a key change sent with other fields.
Project secret API keys still cannot reach the PATCH. The MCP update
tool exposes the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o spec/code-managed-templates/490

# Conflicts:
#	products/messaging/backend/presentation/views/message_templates.py
#	products/messaging/frontend/generated/api.schemas.ts
A template uploaded from code gets a "Managed by code" tag on its card and
page, a source line that links its file, repository and commit on known
hosts, and a notice that edits here can't be saved. A keyed template shows
its key, and the API tab sends through the send route by key or id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An upload with `if_not_superseded` set whose content hash is in the template's
superseded list writes nothing and answers `200 superseded`, naming the current
hash. Without the option an upload publishes as before, so an explicit upload
stays the rollback path. The MCP upload tool describes the option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code-managed template page now shows a read-only preview (subject,
preheader, HTML in a sandboxed frame, plain text) in place of the email
editor, with no Save, Discard or Save as new template. The form of a
code-managed template never diverges from the server copy and its submit
sends nothing, so neither a save nor any draft autosave can write it.

"Stop managing in code" in the page menu asks for confirmation, sends the
release PATCH and reloads the page as an editable template. Delete is
disabled on the page and the library card until the template is released.
Duplicate on both surfaces shares one copy payload that carries only the
name, description and content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An upload checks workflow editor access before it answers `unchanged`
  or `superseded`, so a member without access can't rewrite the source.
- An upload repairs a stored hash that does not describe the published
  content, so a save that skipped the hash no longer loops on
  `template_outdated`.
- Keys validate as whole strings, and the send no longer trims the key,
  so `welcome\n` is refused instead of stored or silently matched.
- The PATCH schema declares the release (`managed_by: gui`). The MCP
  update tool leaves it out.
- The shared transactional email API pieces move out of the template
  view into their own module. One PATCH matcher, one refusal base and
  one usage capture replace the pairs that adoption, release, upload
  and send each built. The key constraint name lives on the model.
- The line-ending hash case checks a fixed digest instead of the
  function under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A template save no longer sends the key, owner or source, which only
  the server sets, so a key set elsewhere never blocks a save.
- Duplicate shows a loading state while it runs and the real error when
  it fails.
- Links in the read-only preview open in a new tab instead of replacing
  the preview.
- A long key truncates instead of overflowing the header.
- Source links: Bitbucket branch and commit paths, encoded branch names,
  and GitLab projects in subgroups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Links in the read-only preview open in a new tab even when the email
  sets its own target. The preview sets the target on every link and
  keeps the email's doctype. Its test goes through the rendered frame.
- An upload refused for missing workflow access reports its usage event.
- A long key truncates with an ellipsis and keeps its copy button.
- The single-field PATCH matcher moves from the facade contract into
  the ownership service, so the contract stays plain data.
- A GitLab remote with a trailing slash links to the right project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A member who released a code-managed template and edited it could lose
the edits when an upload claimed the template back: the save answered
403 immutable, the page toasted and stayed editable until a reload.

Now a save refused with immutable reloads the template, so the page
turns read-only with the managed-by-code notice, and a warning banner
offers "Duplicate with my changes". The duplicate uses the shared copy
payload with the unsaved values, so it carries no key, ownership,
source or hash. Closing the banner discards the edits.

Refs #508

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The final review found two gaps in the refused-edits flow:

- A failed reload after `403 immutable` left the page editable, and
  closing the banner did not discard the edits. The logic now marks the
  loaded copy as managed by code and resets the form at once, then
  reloads. Only the offered duplicate keeps the refused edits.
- A duplicate still navigated to the copy after the person had left the
  page. Both duplicate listeners now check their breakpoint after the
  request, before they navigate.

Refs #508

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The template API returns change_prompt_starter and change_prompt_context
on a template managed by code, and null on any other. One builder in the
messaging product makes both, so the web page, PostHog AI, Slack and any
MCP client hand a coding agent the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…our agent

A code-managed template shows "Edit with your agent" in the Save slot. It opens a
popover where the person completes the server's starter sentence and copies it,
followed by a blank line and the server's change prompt context. Nothing is saved.

Copy waits until the sentence describes a change. The sentence stays in page state
while the popover closes and reopens. The notice now points at the file or a
coding agent.

Events: agent prompt opened and copied, template duplicated, and released from
code. No prompt text, path or repository goes into their properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The side menu of "Edit with your agent" offers "Copy HTML file". It copies
the published template as the exact file posthog-node's templateFile()
reads: the subject goes into the first <title> and the preheader into the
first <meta name="preheader">, each added when missing. A tag whose value
already matches stays as it is, so a template uploaded from a file copies
back byte for byte. A blank preheader removes its tag, and Liquid stays
literal.

Nothing is saved. The toast says how the file goes live, and notes when
the saved template has a plain-text part the file can't carry.

Event: message template html file copied {template_id, dropped_text_part}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-agent-edit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Oct 17, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

Silthus and others added 4 commits October 17, 2026 10:48
…of the change prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ble list

The lazy regex rescanned the rest of the HTML after every unclosed opener, so a large upload made
every list and retrieve quadratic. change_prompt_variables gives the page the server's list, so the
frontend no longer rescans the prompt's prose for its usage event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ied HTML file

A blank preheader now removes every preheader tag, not only the first. Liquid inside the subject or
preheader escapes the character references and the closing title tag the SDK would otherwise decode
or stop at, so the file reads back as the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and keep focus on the agent button

- variable_count reads change_prompt_variables instead of rescanning the prompt's prose
- Duplicate with my changes and the library card's Duplicate fire message template duplicated
- a reload that points the template at another file drops a sentence typed for the old file
- the trigger announces its popover and gets focus back when the popover closes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 6 commits October 17, 2026 11:05
A scroll container inside the collapse inside the popover's 3D transform stays unpainted in
Chromium until it scrolls, so the prompt now grows inside the popover's own scroll area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only whitespace, backticks and control characters can carry an instruction in a path, so paths like
app/(marketing)/emails/[locale]/welcome.html stay known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… full prompt

- focus still inside the closing prompt goes back to the trigger; the popover keeps its content
  mounted for its exit, so the earlier body-only check never fired
- the prompt panel is a labelled dialog, and Copy prompt sits above Show full prompt
- the full prompt is a div, since global styles make every pre a scroll container
- a blank preheader removes every preheader tag in one pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to Silthus/posthog that referenced this pull request Oct 17, 2026
Silthus added a commit to Silthus/posthog that referenced this pull request Oct 17, 2026
Silthus added a commit to Silthus/posthog that referenced this pull request Oct 17, 2026
Silthus added a commit to Silthus/posthog that referenced this pull request Oct 17, 2026
Silthus added a commit to Silthus/posthog that referenced this pull request Oct 17, 2026
…s focus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant