Skip to content

Request a change: start a PostHog Code task that opens a PR for a code-managed template #513

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 "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 answers 201 {task_id, task_url, repository}. Typed request, response and error serializers with help_text.
  • Auth.
    • Session, personal API key and OAuth callers with task:write and read access to the template.
    • Project secret API keys are refused.
    • The existing flag workflows-transactional-email-api applies, 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.
  • Task. The action starts a PostHog Code task through the tasks facade (create_and_run_task):
    • repository: the owner/name from source_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 and source.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}, with surface from the request's event source (web, MCP, API). No instruction text.
  • MCP.
    • A new tool workflows-request-email-template-change in products/workflows/mcp/email_templates.yaml, under the same flag, with scope task:write and 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_path itself, following change_prompt_context, and does not call this tool;
      • pass the person's request as instruction, and share the returned task_url;
      • a repeated call starts another task.
    • The update, patch and design tool descriptions, and the fix of the lock's 403 immutable, say for a code-managed template, in this order:
      1. edit <path> in <repository> yourself when you can reach the repository; the next send uploads it;
      2. otherwise call workflows-request-email-template-change.
    • The retrieve tool description says to read managed_by and change_prompt_context before changing a template.
    • Regenerate the OpenAPI, frontend types and MCP schema snapshots.
  • No idempotency key. The UI guards double submission, and the tool description warns.

Test seams.

  • Django APIBaseTest on 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 immutable fix text.
  • Prior art:
    • the message template API tests;
    • other products' create_and_run_task callers, 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.
  • 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 immutable fix 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)

  • /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-513-1
    Cast: Opus 5.5, high.

  3. Silthus commented on Oct 17, 2026

    @Silthus
    OwnerAuthor

    Resolution

    Landed: 804a0b87d6c on spec/template-request-change (fast-forward 0ba8f7972c2..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.run and create with start_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 in template_change_requests._require_posthog_code and 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

    • Route. POST /api/projects/{project_id}/messaging_templates/{id}/request_change/ with {instruction} (required, at most 2,000 characters). It answers 201 {task_id, task_url, repository}. Request, response and error serializers carry help_text. Errors use the Spec: code-managed email templates #485 envelope.
    • Callers. Session, personal API key and OAuth with task:write (required_scopes on the action). Project secret API keys are refused, because the action is not in psak_allowed_actions.
    • Gates, in order:
      1. the workflows-transactional-email-api flag (404);
      2. object permissions, then the ownership policy: request_change joins ALLOWED_ACTIONS for code-managed templates, and a new FILE_ACTIONS set refuses it on any other template (template_not_managed_by_code);
      3. the instruction (instruction_missing, or invalid_request past 2,000 characters);
      4. PostHog Code available (posthog_code_not_available, 403): not from a sandbox, Desktop access through tasks_access.get_desktop_access_decision, and credits through tasks_usage.task_run_usage_limited. These are the gates the tasks run endpoint and the commands QA handler use;
      5. the repository: github.com/<owner>/<name> parsed from source_repository (template_repository_missing, template_repository_unsupported);
      6. the project's own GitHub integration reaches it, through tasks_repo_selection.list_team_connected_repositories (github_repository_not_connected). This is the experiments cleanup PR's check;
      7. tasks_facade.create_and_run_task. A ValueError or ComputeBillingLimitExceeded from it becomes posthog_code_run_failed, with its message as detail.
    • Task.
      • repository owner/name, create_pr=True, origin user_created.
      • Title "Change the <key> email template".
      • Description: the request line, Code-managed templates: the change prompt on the template API #512's block, then "Change only this file. Open a pull request. Do not upload the template or call PostHog's email template tools."
      • posthog_mcp_scopes="read_only".
    • Server event. message template change requested {template_id, surface}, sent through report_user_action. surface is get_event_source(request): web, api, mcp, and so on. The event carries no instruction text.
    • Code.
      • New service: services/template_change_requests.py.
      • Facade: facade/templates.request_template_change.
      • Contracts: TemplateChangeRefused and TemplateChangeRequested.
      • handle_exception maps immutable and posthog_code_not_available to 403 and every other refusal to 400.
      • tach.toml: messaging depends on products.tasks, facade only.
    • MCP (products/workflows/mcp/email_templates.yaml):
      • New tool workflows-request-email-template-change: flag workflows-transactional-email-api, scope task:write, not read-only, not idempotent. Its description follows the spec, including "each call starts another task".
      • The update and patch tools say, in order: 1. edit the file at source_path in source_repository yourself when you can reach it, following change_prompt_context, and the next send uploads it; 2. otherwise call the new tool.
      • The get tool says to read managed_by and change_prompt_context before changing a template.
    • 403 immutable fix, verbatim for a template with path and repository: "When you can reach the repository, edit emails/welcome.html in github.com/example/app yourself. The next send uploads it. Otherwise, ask PostHog Code to open a pull request with the change: call the workflows-request-email-template-change tool, or POST an instruction to this template's request_change/ route. To edit the template in PostHog instead, stop managing it in code first: send a PATCH whose only field is managed_by set to gui."
    • Generated by hogli build:openapi: messaging frontend types, the MCP client and tool definitions, and the new tool's schema snapshot.

    Proof

    Test seams.

    • APIBaseTest on the route, in the new file tests/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_limited and list_team_connected_repositories.
    • The exact fix text, in the existing test_the_fix_names_the_file_as_far_as_the_template_records_it.
    • The MCP tool schema snapshot.

    Red, on 0ba8f7972c2 with the tests added: 23 failed, 10 passed.

    • Every route test failed on {'code': 'not_found', 'detail': 'Endpoint not found.'}. That includes test_refuses_callers_that_may_not_start_a_task_0_project_secret_api_key and the two cases of test_starts_a_posthog_code_task_that_opens_a_pull_request_and_leaves_the_template_unchanged, which assert the description with the block.
    • The three fix-text rows failed on the old "Change … and upload it again." text.

    Mutation: adding request_change to psak_allowed_actions fails the project secret key test:

    E   AssertionError: {'task_id': "<MagicMock …>", 'task_url': "http://localhost:8010/project/68/tasks/<MagicMock …>", 'repository': 'example/app'}
        assert 201 == 403
    1 failed in 13.33s
    

    Green: both test files 33 passed. The whole products/messaging/backend suite: 405 passed in 39.97s.

    Case Asserts
    session, agent sentence / completed starter sentence 201 body; exact task kwargs (team, user, user_created, example/app, create_pr, read_only, title, full description); one event with surface: web and no instruction text; template content_hash and updated_at and workflow version unchanged
    personal API key / OAuth (MCP header) with task:write 201, surface api / mcp
    project secret API key (task:write scope) / personal key and OAuth without task:write 403, no task, no event
    blank / absent / 2,001-character instruction instruction_missing ×2 / invalid_request, attr: instruction
    no repository / GitLab / example / not connected template_repository_missing / template_repository_unsupported ×2 / github_repository_not_connected
    template made in PostHog template_not_managed_by_code
    no Desktop access / access not verifiable / credits used up / sandbox header posthog_code_not_available, 403, no task
    facade raises ValueError posthog_code_run_failed, detail passed through, no event
    flag off 404, no task

    Local gate on the landed tree:

    • uv run mypy --cache-fine-grained . through fleet heavy: Success: no issues found in 24125 source files.
    • tsgo --noEmit --checkers 1 in frontend/ through fleet heavy: exit 0.
    • MCP: typecheck exit 0, lint-tool-names pass. tool-schema-snapshots, instructions and generate-tools: 177 passed.
    • posthog/test/repo_invariants and posthog/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.
    • The pre-push hook (hogli ci:preflight --strict): "failures": 0.

    Local proof.

    • Setup: Django from this worktree on a scratch database cloned from a migrated test database, with DEBUG=1.
    • Patched: only create_and_run_task, at the tasks facade boundary (it records its arguments and starts nothing), and the workflows-transactional-email-api flag (forced on).
    • Ran for real: the Desktop access gate (allowed under DEBUG), the credits gate, and the GitHub check against a GitHub integration row whose repository cache holds example/app.
    • A live PostHog Code run is not possible here: this devbox has no local PostHog Code sandbox and no GitHub app installed on 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
    ### 1. upload emails/welcome.html from github.com/example/app
    $ curl -X PUT http://127.0.0.1:8513/api/projects/69/messaging_templates/by_key/welcome/ -H 'Authorization: Bearer phx_…' -d '{name, subject, html, source}'
    {
     "id": "01a1491b-f6e8-0000-81c3-81cb862c82dd",
     "key": "welcome",
     "result": "created",
     "content_hash": "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6",
     "managed_by": "code",
     "transactional_hog_flow_id": "01a1491b-f6f0-0000-6647-e193ac8d560d"
    }
    
    ### 2. template and workflow before
    {
     "template.content_hash": "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6",
     "template.updated_at": "2026-10-17T09:05:31.896130Z",
     "template.name": "Welcome",
     "workflow.version": 1,
     "workflow.updated_at": "2026-10-17T09:05:31.889199Z"
    }
    
    ### 3. request a change (personal API key with task:write)
    $ curl -X POST http://127.0.0.1:8513/api/projects/69/messaging_templates/01a1491b-f6e8-0000-81c3-81cb862c82dd/request_change/ -H 'Authorization: Bearer phx_…' -d '{"instruction": "Make the button green."}'
    {"task_id":"006d69e2-42f8-4e0a-9636-8b0f27cf5e6c","task_url":"http://localhost:8010/project/69/tasks/006d69e2-42f8-4e0a-9636-8b0f27cf5e6c","repository":"example/app"}
    HTTP 201
    
    ### 4. template and workflow after
    {
     "template.content_hash": "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6",
     "template.updated_at": "2026-10-17T09:05:31.896130Z",
     "template.name": "Welcome",
     "workflow.version": 1,
     "workflow.updated_at": "2026-10-17T09:05:31.889199Z"
    }
    
    ### 5. the task the route handed to the tasks facade
    {
     "team": 69,
     "user_id": 29,
     "origin_product": "user_created",
     "repository": "example/app",
     "create_pr": true,
     "posthog_mcp_scopes": "read_only",
     "title": "Change the `welcome` email template"
    }
    description:
    In `emails/welcome.html`, change what this request asks for: Make the button green.
    
    About this file:
    - It is the PostHog email template with the key `welcome`, in the repository `github.com/example/app`, last uploaded from commit `3f2c1ab`.
    - 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 }}`.
    - 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/69/workflows/library/templates/01a1491b-f6e8-0000-81c3-81cb862c82dd
    
    Change only this file. Open a pull request. Do not upload the template or call PostHog's email template tools.
    
    ### 6. the same request with a project secret API key
    $ curl -X POST http://127.0.0.1:8513/api/projects/69/messaging_templates/01a1491b-f6e8-0000-81c3-81cb862c82dd/request_change/ -H 'Authorization: Bearer phs_…' -d '{"instruction": "Make the button green."}'
    {"type":"authentication_error","code":"permission_denied","detail":"This action does not support project secret API key access","attr":null}
    HTTP 403
    
    ### 7. an edit in PostHog: 403 immutable
    $ curl -X PATCH http://127.0.0.1:8513/api/projects/69/messaging_templates/01a1491b-f6e8-0000-81c3-81cb862c82dd/ -H 'Authorization: Bearer phx_…' -d '{"name": "Renamed in PostHog"}'
    {
     "type": "invalid_request",
     "code": "immutable",
     "detail": "This template is managed by code, so it can't be changed in PostHog.",
     "attr": null,
     "why": "Its content comes from a file in a repository. The next upload of that file replaces changes made here.",
     "fix": "When you can reach the repository, edit `emails/welcome.html` in `github.com/example/app` yourself. The next send uploads it. Otherwise, ask PostHog Code to open a pull request with the change: call the `workflows-request-email-template-change` tool, or POST an `instruction` to this template's `request_change/` route. To edit the template in PostHog instead, stop managing it in code first: send a PATCH whose only field is `managed_by` set to `gui`.",
     "docs_url": "https://posthog.com/docs/workflows/transactional-email-api#immutable"
    }
    HTTP 403
    
    ### 8. template and workflow at the end
    {
     "template.content_hash": "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6",
     "template.updated_at": "2026-10-17T09:05:31.896130Z",
     "template.name": "Welcome",
     "workflow.version": 1,
     "workflow.updated_at": "2026-10-17T09:05:31.889199Z"
    }
    

    Calls made

    • Sandbox requests refused. See the decision box above.
    • Read-only PostHog scopes for the task. The task edits a file and opens a pull request, so it gets no PostHog write access. The template stays unchanged whatever the task does.
    • The starter ties an agent's sentence to the file. This follows Code-managed templates: the change prompt on the template API #512's follow-up. When the instruction doesn't start with change_prompt_starter, the request line becomes In `emails/welcome.html`, change what this request asks for: <instruction>. The web button's completed sentence stays as written.
    • Editor access on workflows, not read access. The access-control layer maps any :write scope to editor level on the viewset's resource (hog_flow). With required_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.
    • One status per code.
      • immutable and posthog_code_not_available answer 403. All other refusals answer 400.
      • A Desktop access check that fails to resolve also answers posthog_code_not_available, with "Try again in a few minutes." as the fix. The tasks product answers a 503 there.
      • A used-up credit balance answers 403, where the tasks product answers 429. This keeps the error list at the seven codes the spec names.
    • Repository format. Only github.com/<owner>/<name> is accepted, case-insensitive, with an optional .git. That is the form posthog-node and the skill store. Anything else is template_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.
    • Long keys. A key can have 400 characters, but a task title has at most 255. The title is cut to 255 with an ellipsis, so a long key can't make the route fail.
    • The fix text keeps the release sentence from Email templates: refuse edits to code-managed templates on the server #491 after the two steps. It names the tool and the route, because REST callers get the same 403.
    • "Design tool" is the patch tool, workflows-patch-email-template, which wraps the design route. There is no separate design tool.
    • No review in this ticket. The ship ticket Ship stack B: request a change through PostHog Code #517 owns review through qa-swarm.

    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