Repository navigation
Conversation
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>
…o spec/code-managed-templates/490
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>
…o spec/code-managed-templates/499
- 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>
…PostHog Code
POST messaging_templates/{id}/request_change/ with an instruction starts a PostHog Code
task that edits the template's file in its GitHub repository and opens a pull request.
PostHog never changes the template: the next upload of the file publishes it.
- Callers: session, personal API key and OAuth with task:write. Project secret API keys
and requests from inside a PostHog Code sandbox are refused.
- Gates: the transactional email API flag, PostHog Code access and credits, a GitHub
repository the project's integration reaches, and a template managed by code.
- The task gets read-only PostHog MCP scopes, so it can't write the template either.
- New MCP tool workflows-request-email-template-change. The 403 immutable fix and the
update, patch and get tool descriptions send agents to the file first.
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>
…pec/template-request-change/514
…popover
A code-managed template's "Edit with your agent" popover gains "Run with
PostHog Code" next to "Copy prompt". It sends the sentence as typed to the
template's request_change route, which starts a PostHog Code task that opens
a pull request. PostHog doesn't change the template.
- The button shows only when the project can use PostHog Code (the desktop
access check the route uses).
- It is disabled until the template has a GitHub repository, the project has
a GitHub integration, and the change is described.
- One request at a time: a second click while the request is in flight sends
nothing.
- On success a toast and the code-managed notice link the task, the sentence
goes back to the starter, and the popover closes.
- On a refusal the popover shows the server's detail and fix, and keeps the
sentence and Copy prompt.
- Events: message template change run requested {template_id} and message
template change run failed {template_id, code}.
The code-managed notice moves to its own component, so a story can show it
with the task link.
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>
…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>
…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>
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>
…s focus Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pec/template-request-change/515 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…emplate tools A sandboxed case can now set `prompt_from_seed` to build its first prompt from the `setup` result. A suite that replays the page context a product attaches needs this, because that context names a seeded id. The agent and the scorers get the built prompt; `prompt` stays the static case input. The eval MCP server now forces `workflows-transactional-email-api` on, so it serves the code-managed email template tools that Django already serves under the harness's flag stub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot in PostHog
- Skill: `designing-email-templates` gains "Code-managed templates". An agent
checks `managed_by`, never calls the update or patch tool on a code-managed
template, and changes the file where it lives: itself when it can reach the
repository, through the template's page ("Run with PostHog Code") when it runs
inside PostHog Code, and through `workflows-request-email-template-change`
otherwise.
- Page context: a code-managed template's page attaches the template, its key
and source as untrusted items, and a static instruction that points at the
skill section and the page's buttons.
- Tool description: the request tool says it refuses calls from inside PostHog
Code, and what to do there instead.
- Evals: `workflows/eval_code_managed_templates` runs the sidebar, a Slack
message and a template made in PostHog against a seeded project.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merging to
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 |
- request_change needs hog_flow:read next to task:write, so a token that
can't read workflow templates can't start a task on one.
- The 403 immutable fix and the update/patch tool texts point at
change_prompt_context for how a change goes live, add the PostHog Code
branch, and name the file only when its uploaded path has a file's shape.
- A source path with a `..` segment counts as unknown in the change prompt.
- The task description no longer contradicts the by-key upload line.
- The sandbox refusal names the page steps; not-connected mentions delay.
- Refusals send `message template change refused` {template_id, surface, code}.
- The skill forbids rebuilding and uploading a code-managed template from
PostHog, and keeps check-outs to agents outside PostHog Code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The button names the missing workflow editor access before the click, instead of a 403 after it, and refuses a change longer than the route takes, using the generated limit. - The page checks only that the template records a repository; the route owns which repositories PostHog Code can work in, so the GitHub rule lives in one place. - A refusal that arrives after the popover closed shows as a toast. - Editing the sentence clears an old refusal; long names wrap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d template eval - The sidebar case reads a fixture that templateAgentContext.test.ts renders from the page's own context builder, instead of a hand copy in Python. The seeder test checks the fixture names the seeded template. - Each code-managed template page gets its own dismiss group, so closing one page's chip no longer strips the context from every other. - The scorer counts an upload by the template's key as touching it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `message template change refused` now also fires for a template made in PostHog and for a missing or oversized instruction. - The 403 immutable fix and every tool text use one wording for when an agent edits the file itself: the repository is in its workspace, or it runs outside PostHog Code and can check it out. - The upload tool says to upload a code-managed template only from its file. - Refusal fixes point at change_prompt_context instead of "then upload it", and `template_repository_unsupported` names only a repository-shaped value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the route - The code-managed page's skill chip is not dismissible, like the new-template page's: closing it dismissed the shared skill key on every page. - The 2,000-character check counts code points, as the route does. - A discarded sentence drops its refusal banner. - The eval fixture ends with a newline; the seeder test ties its name line. 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
This was referenced Oct 17, 2026
4 of 5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Stacks on #118980 (
spec/template-agent-edit), which stacks on #118924. GitHub can't base a PR on a fork branch, so this targetsmaster. Review starts after stack A's tip,0960e3de116:git diff 0960e3de116...spec/template-request-change.Problem
A person who can't reach a code-managed email template's repository has no way to get the template changed.
The template page is read-only (#118924), and #118980 only hands the person a prompt for their own coding agent.
A marketer without a coding agent, PostHog AI in the sidebar, and the Slack bot can't start the change.
Caution
🔴 Merge risk: high · one-way door (merge-risk rubric)
workflows-transactional-email-api(off). Project secret API keys and calls from inside PostHog Code are refused, callers needhog_flow:readandtask:writeplus editor access to workflows, and the task opens a pull request that a person merges. PostHog never saves, uploads or syncs the template.Changes
On a code-managed template, "Edit with your agent" gains Run with PostHog Code. Only flagged uploads make a template code-managed, so nobody sees it today.
POST /api/projects/{id}/messaging_templates/{id}/request_change/with{instruction}starts a PostHog Code task. The task edits the template's file in its GitHub repository and opens a pull request. The response is201 {task_id, task_url, repository}.hog_flow:readandtask:write, and editor access to workflows. Project secret API keys get 403. A call from inside a PostHog Code sandbox getsposthog_code_not_available, so PostHog AI and the Slack bot hand the person the template link instead.code,detail,why,fix,docs_url):template_not_managed_by_code,template_repository_missing,template_repository_unsupported,github_repository_not_connected,posthog_code_not_available,instruction_missing,posthog_code_run_failed.detailandfixin the popover, or as a toast when the popover was closed. Success shows a toast and a task link in the notice.workflows-request-email-template-change. Thedesigning-email-templatesskill, the tool texts, the403 immutablefix and the sidebar page context say this in the same order.message template change run requestedand… run failed(button),message template change requestedand… change refused(server, withsurface). No instruction text, path or repository in properties.tach.tomllets messaging use the tasks facade.Captures from the real app built from this branch at
0c3c8b87b51, on a scratch project with invented content (Hedgebox,example.com):detailandfixin the popover (560px)Reading order
804a0b87d6c403 immutablefix text60fb98f06a5fbbbe6ac4f1,df551ee9fb6495bca44dec,85da57524796a8e0376690,e00a29226a4,29a52f31796hog_flow:readscope, agent texts point atchange_prompt_context,..paths unknown, refusal event; button access and length reasons, toast when closed; eval reads the page's real contextfc5dbc9aa98,0c3c8b87b51How did you test this code?
Live run, Django built from this branch on a scratch database, with the flag forced on and
create_and_run_taskwrapped to passstart_workflow=False. That creates a real Task row and starts no sandbox. The Desktop access, credits and GitHub repository checks ran for real. At0c3c8b87b51:example/app,create_pr, read-only PostHog scopes, and the full description. The template's hash and its workflow's version stayed unchanged.task:write, a key withouttask:write, and a call with the sandbox header got 403. A template made in PostHog gottemplate_not_managed_by_code.detailandfixin the popover, and success shows the toast and the notice link.Shadow diff, base
0960e3de116against HEAD, the same requests on identical scratch databases. Upload, list, GUI edits and release answer byte for byte the same. The only differences are the new route, the new403 immutablefix text, and a source path with..now being unknown in the prompt.Test rationale:
test_template_change_request.py(APIBaseTest, templates made by the real upload route, the tasks facade mocked at its function boundary as the spec asks): the exact task arguments and description, every refusal code, the server events, project secret keys and tokens withouthog_flow:readortask:writerefused, and the template and workflow unchanged.test_code_managed_template_lock.py: the exact403 immutablefix, including a path that could carry an instruction and one that leaves the repository.test_template_change_prompt.py: a..path is unknown.messageTemplateLogic.test.ts(API mocked at HTTP): the exact request body, one request on a double click, each disabled reason, availability, success, refusal in the popover and as a toast.templateAgentContext.test.ts: the page context, a closed chip on one template keeping every other page's context (through the context store), and the eval's sidebar fixture rendered from the real builders.Local gate on HEAD: messaging backend, eval harness, MCP tool definitions and repo invariants 1,615 passed;
mypyrepo-wide clean; frontendtsgoclean; TemplateLibrary jest 112 passed; MCP typecheck, tool schema snapshots and tool-name lint;hogli build:openapicommitted; pre-pushci:preflight --strict0 failures.Gaps:
products/workflows/evals/eval_code_managed_templates.pyis written, discovered and unit-tested, but this devbox has no LLM gateway, Braintrust or Modal credentials. Run it where they exist, from a checkout of this branch in a flox shell:Release status
Docs update
The docs section on changing a code-managed template, with the request-change error anchors, 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 it adds a billable task with repository write access to a public API and an MCP tool. Skills:/improving-drf-endpoints,/implementing-mcp-tools,/writing-evals,/writing-kea-logics,/writing-tests,/writing-user-facing-copy,/writing-pr-descriptions. Every screenshot and fixture uses invented data (example.com, Hedgebox). CodeRabbit is retired on this repository, so it did not run.Decisions
request_changerefuses itIn `<path>`, change what this request asks for:in frontgithub.com/<owner>/<name>, the form posthog-node and the skill store; a bareowner/nameis refused as unsupportedimmutableandposthog_code_not_available(including used-up credits and an unverifiable access check), 400 for the rest; keeps the spec's seven codesdesktop/access/, the decision the route checks; credits are left to the route's refusaldetailandfix; a toast only when the popover is closedPOSTHOG_TASK_RUN_ID, which every sandbox sets:writescope; the button names it before the click. Replaced the spec's "read access"; the alternative is a custom per-action permission at viewerhog_flow:readnext totask:write, as the spec saysmessage template change refused{template_id, surface, code}, including ownership and instruction refusalsInvolvement: no grilling rounds for this stack; 1 prototype on the map (4 rounds); QA swarm with 2 panel rounds and 1 final read (GPT-6.1 Sol high: correctness, risk, design, systems, author perspective; Opus 5.5 high: fresh eyes, UI, agent documents); live run and real-app UI run captured; shadow diff run.
Closes Silthus#513
Closes Silthus#514
Closes Silthus#515
Closes Silthus#502
🤖 Generated with Claude Code