Repository navigation
Request a change: start a PostHog Code task that opens a PR for a code-managed template #513
Copy link
Copy link
Closed
Labels
Description
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 "Request a change: one action for every surface (stack B)"
Cast: Opus 5.5, high
Repository: PostHog/posthog (backend and MCP)
Stack: B, first slice, on top of stack A
Merge risk: danger areas.
- A write to a third-party repository: a PostHog Code task opens the PR.
- Auth and scopes: a new action needing
task:write, open to OAuth and personal keys, never to project secret API keys. - The public API and MCP: a new route and a new agent tool.
- Billing: a PostHog Code run is billable.
It ships through qa-swarm.
Question
Can any caller turn one sentence into a PostHog Code task that changes a code-managed template's file and opens a PR, without PostHog changing the template?
Delivers end to end.
- Route.
POST /api/projects/{project_id}/messaging_templates/{id}/request_change/with{instruction}(required, at most 2,000 characters). It answers201 {task_id, task_url, repository}. Typed request, response and error serializers withhelp_text. - Auth.
- Session, personal API key and OAuth callers with
task:writeand read access to the template. - Project secret API keys are refused.
- The existing flag
workflows-transactional-email-apiapplies, and so does the gate that decides whether PostHog Code is available to the project (the same one task creation uses). - The ownership policy allows the action on code-managed templates only.
- Session, personal API key and OAuth callers with
- Task. The action starts a PostHog Code task through the tasks facade (
create_and_run_task):repository: theowner/namefromsource_repository(GitHub only);create_pr: true;- title: "Change the
<key>email template"; - description: the instruction, then the change prompt block from Code-managed templates: the change prompt on the template API #512, then "Change only this file. Open a pull request. Do not upload the template or call PostHog's email template tools.";
- origin: the existing generic user-created origin.
- It never calls save, upload, release or workflow sync.
- Errors, in the Spec: code-managed email templates #485 envelope (
code,detail,why,fix,docs_url):template_not_managed_by_code;template_repository_missing(the fix names the SDK's detection andsource.repository);template_repository_unsupported(not GitHub);github_repository_not_connected;posthog_code_not_available;instruction_missing;posthog_code_run_failed(the task's own start error, passed through).
- Server event:
message template change requested{template_id, surface}, withsurfacefrom the request's event source (web, MCP, API). No instruction text. - MCP.
- A new tool
workflows-request-email-template-changeinproducts/workflows/mcp/email_templates.yaml, under the same flag, with scopetask:writeand annotations not read-only and not idempotent. Its description says:- it is the fallback for agents that cannot edit the template's file;
- an agent that can reach the repository (a coding agent in the user's workspace) edits the file at
source_pathitself, followingchange_prompt_context, and does not call this tool; - pass the person's request as
instruction, and share the returnedtask_url; - a repeated call starts another task.
- The update, patch and design tool descriptions, and the
fixof the lock's403 immutable, say for a code-managed template, in this order:- edit
<path>in<repository>yourself when you can reach the repository; the next send uploads it; - otherwise call
workflows-request-email-template-change.
- edit
- The retrieve tool description says to read
managed_byandchange_prompt_contextbefore changing a template. - Regenerate the OpenAPI, frontend types and MCP schema snapshots.
- A new tool
- No idempotency key. The UI guards double submission, and the tool description warns.
Test seams.
- Django
APIBaseTeston the route, with session, OAuth (task:write), personal API key and project secret API key callers. The tasks facade is mocked at its function boundary, the one place this product calls out. Cover:- each error code;
- the exact task arguments (repository, title, description text,
create_pr); - the server event;
- the template row and the linked workflow unchanged.
- The MCP tool snapshot test, and a test on the exact
403 immutablefixtext. - Prior art:
- the message template API tests;
- other products'
create_and_run_taskcallers, such as experiments and account audits; - the MCP analytics "create a fix task" button for the task shape.
Write scope.
products/messaging/backend/: a new service,facade/templates.py,facade/contracts.py,presentation/views/message_templates.py,services/template_ownership.py, the error module, tests.products/workflows/mcp/email_templates.yaml.- Generated files.
Proof.
- Red: the "project secret API key is refused" test and the "task description carries the block" test fail.
- Green: the API and MCP tests pass.
- Local:
- with PostHog Code available on the dev stack and a GitHub integration reaching the private scratch repository
Silthus/posthog-workflows-github-scratch, upload a template whose file lives there, then call the route with cURL; - show the task, its PR changing only that file, and the template's hash unchanged.
- with PostHog Code available on the dev stack and a GitHub integration reaching the private scratch repository
- If the dev stack cannot run PostHog Code, record the gap and prove with the mocked facade only. Never point a task at any other repository.
Acceptance criteria
- One route serves the web button and every MCP client; project secret API keys are refused.
- The task description is the instruction plus Code-managed templates: the change prompt on the template API #512's block, verbatim.
- Tool descriptions and the
403 immutablefix send agents with repository access to the file first, and only the others to this route. - The template and its workflow are unchanged in every test.
Blockers
Blocked by #512 (the change prompt block).
How to build (map #461)
/tddred-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 frommaster. - 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.
Reactions are currently unavailable
Activity
Silthus commentedon Oct 17, 2026
Runtime: T3 child task
Task:
node:delegated-task:command%3Amcp%3A1778d880-5a25-404c-80d0-25fc5ccca3b2%3Adelegate-task%3Awf-email-impl-513-1Cast: Opus 5.5, high.
Silthus commentedon Oct 17, 2026
Resolution
Landed:
804a0b87d6conspec/template-request-change(fast-forward0ba8f7972c2..804a0b87d6c, one commit).Branch:
spec/template-request-change/513. No PR: the ship ticket #517 opens it.Important
One decision for Michael, before #515 dispatches. The route refuses requests that come from inside a PostHog Code sandbox with
posthog_code_not_available. That covers PostHog AI and Slack runs, because their MCP tokens are sandbox tokens. The spec routes both through this tool. The tasks product applies the same rule to its own run starts (TaskViewSet.runandcreatewithstart_run), and autoresearch applies it to training runs: a task already running can't start another one. I kept that rule instead of opening an exception. Lifting it means removing one check intemplate_change_requests._require_posthog_codeand one test row. Keep the rule, so PostHog AI and Slack hand the person the template link, or open an exception for this route?What landed
POST /api/projects/{project_id}/messaging_templates/{id}/request_change/with{instruction}(required, at most 2,000 characters). It answers201 {task_id, task_url, repository}. Request, response and error serializers carryhelp_text. Errors use the Spec: code-managed email templates #485 envelope.task:write(required_scopeson the action). Project secret API keys are refused, because the action is not inpsak_allowed_actions.workflows-transactional-email-apiflag (404);request_changejoinsALLOWED_ACTIONSfor code-managed templates, and a newFILE_ACTIONSset refuses it on any other template (template_not_managed_by_code);instruction_missing, orinvalid_requestpast 2,000 characters);posthog_code_not_available, 403): not from a sandbox, Desktop access throughtasks_access.get_desktop_access_decision, and credits throughtasks_usage.task_run_usage_limited. These are the gates the tasks run endpoint and the commands QA handler use;github.com/<owner>/<name>parsed fromsource_repository(template_repository_missing,template_repository_unsupported);tasks_repo_selection.list_team_connected_repositories(github_repository_not_connected). This is the experiments cleanup PR's check;tasks_facade.create_and_run_task. AValueErrororComputeBillingLimitExceededfrom it becomesposthog_code_run_failed, with its message asdetail.repositoryowner/name,create_pr=True, originuser_created.<key>email template".posthog_mcp_scopes="read_only".message template change requested{template_id, surface}, sent throughreport_user_action.surfaceisget_event_source(request):web,api,mcp, and so on. The event carries no instruction text.services/template_change_requests.py.facade/templates.request_template_change.TemplateChangeRefusedandTemplateChangeRequested.handle_exceptionmapsimmutableandposthog_code_not_availableto 403 and every other refusal to 400.tach.toml: messaging depends onproducts.tasks, facade only.products/workflows/mcp/email_templates.yaml):workflows-request-email-template-change: flagworkflows-transactional-email-api, scopetask:write, not read-only, not idempotent. Its description follows the spec, including "each call starts another task".source_pathinsource_repositoryyourself when you can reach it, followingchange_prompt_context, and the next send uploads it; 2. otherwise call the new tool.managed_byandchange_prompt_contextbefore changing a template.403 immutablefix, verbatim for a template with path and repository: "When you can reach the repository, editemails/welcome.htmlingithub.com/example/appyourself. The next send uploads it. Otherwise, ask PostHog Code to open a pull request with the change: call theworkflows-request-email-template-changetool, or POST aninstructionto this template'srequest_change/route. To edit the template in PostHog instead, stop managing it in code first: send a PATCH whose only field ismanaged_byset togui."hogli build:openapi: messaging frontend types, the MCP client and tool definitions, and the new tool's schema snapshot.Proof
Test seams.
APIBaseTeston the route, in the new filetests/api/test_template_change_request.py. Templates are created by the real upload route. The tasks facade is mocked at its function boundary:create_and_run_task,get_desktop_access_decision,task_run_usage_limitedandlist_team_connected_repositories.test_the_fix_names_the_file_as_far_as_the_template_records_it.Red, on
0ba8f7972c2with the tests added:23 failed, 10 passed.{'code': 'not_found', 'detail': 'Endpoint not found.'}. That includestest_refuses_callers_that_may_not_start_a_task_0_project_secret_api_keyand the two cases oftest_starts_a_posthog_code_task_that_opens_a_pull_request_and_leaves_the_template_unchanged, which assert the description with the block.Mutation: adding
request_changetopsak_allowed_actionsfails the project secret key test:Green: both test files
33 passed. The wholeproducts/messaging/backendsuite:405 passed in 39.97s.201body; exact task kwargs (team, user,user_created,example/app,create_pr,read_only, title, full description); one event withsurface: weband no instruction text; templatecontent_hashandupdated_atand workflowversionunchangedtask:write201,surfaceapi/mcptask:writescope) / personal key and OAuth withouttask:write403, no task, no eventinstruction_missing×2 /invalid_request,attr: instructionexample/ not connectedtemplate_repository_missing/template_repository_unsupported×2 /github_repository_not_connectedtemplate_not_managed_by_codeposthog_code_not_available, 403, no taskValueErrorposthog_code_run_failed,detailpassed through, no eventLocal gate on the landed tree:
uv run mypy --cache-fine-grained .throughfleet heavy:Success: no issues found in 24125 source files.tsgo --noEmit --checkers 1infrontend/throughfleet heavy: exit 0.typecheckexit 0,lint-tool-namespass.tool-schema-snapshots,instructionsandgenerate-tools:177 passed.posthog/test/repo_invariantsandposthog/test/test_mcp_tool_definitions.py:263 passed.hogli lint:tach: all modules validated.hogli product:lint messaging: all checks passed.hogli build:openapi: exit 0, output committed.hogli ci:preflight --strict):"failures": 0.Local proof.
DEBUG=1.create_and_run_task, at the tasks facade boundary (it records its arguments and starts nothing), and theworkflows-transactional-email-apiflag (forced on).DEBUG), the credits gate, and the GitHub check against a GitHub integration row whose repository cache holdsexample/app.Silthus/posthog-workflows-github-scratch. So that proof is a gap, listed under follow-ups.cURL session: upload, request a change, template and workflow unchanged, project secret key refused, 403 immutable
Calls made
change_prompt_starter, the request line becomesIn `emails/welcome.html`, change what this request asks for: <instruction>. The web button's completed sentence stays as written.:writescope to editor level on the viewset's resource (hog_flow). Withrequired_scopes=["task:write"], a member with viewer access to workflows gets 403. The spec says "read access to the template". Matching it needs a custom permission on this one action. I kept the platform's behavior.immutableandposthog_code_not_availableanswer 403. All other refusals answer 400.posthog_code_not_available, with "Try again in a few minutes." as the fix. The tasks product answers a 503 there.github.com/<owner>/<name>is accepted, case-insensitive, with an optional.git. That is the form posthog-node and the skill store. Anything else istemplate_repository_unsupported, including a value without a host. The check against the project's integration reads the cached repository list, team installation only, as experiments does.workflows-patch-email-template, which wraps thedesignroute. There is no separate design tool.qa-swarm.Follow-ups
posthog_code_not_available(403),template_repository_missing,github_repository_not_connectedandposthog_code_run_failed(400). The201body carriestask_urlfor the toast.Silthus/posthog-workflows-github-scratch(a real task, a PR that changes only the file, the hash unchanged) is still owed. This devbox has no local PostHog Code sandbox and no GitHub app installed for the scratch repository.template_not_managed_by_code,template_repository_missing,template_repository_unsupported,github_repository_not_connected,posthog_code_not_available,instruction_missingandposthog_code_run_failed. Each refusal'sdocs_urlpoints at#<code>.designing-email-templatesskill and page context are still to do. The tool descriptions already put the file first.test_rewrite_syncholds 3 committed message templates, which fail theMessageTemplate.objects.exists()assertions intest_template_upload.pyon a clone. I cleared them in my private clone only.