Skip to content

Auto-PR

Centralized AI coding agent pipeline. Developers can create new PRs from Linear comments and iterate on existing PRs from GitHub comments, without installing workflows in each target repo.

How It Works

The trigger is #auto-pr.

Changed: the trigger used to be @auto-pr. The old form was retired rather than aliased, so it now matches nothing and a comment using it is ignored silently — the worker returns not_a_command and posts no reply. If a command appears to do nothing, check the sigil first.

There are three supported trigger flows:

A) Create a New PR (Linear)

  1. A developer posts on a Linear issue: #auto-pr scenarix/frontend-app Fix the null pointer in the login form
  2. Worker receives /webhook/linear, verifies signature and allowlist, then dispatches to GitHub Actions
  3. Central workflow clones target repo, runs Aider, creates branch/commit, opens a PR
  4. Result is reported back to Linear

B) Iterate on an Existing PR (GitHub)

  1. A developer comments on a PR: #auto-pr Please handle empty input and update tests
  2. Worker receives /webhook/github, verifies signature and allowlist, then dispatches to GitHub Actions
  3. Central workflow checks out that PR head branch, runs Aider, and pushes follow-up commits to the same branch
  4. Result is posted as a PR comment

C) Notify the Reporter on Status Change (Linear)

  1. A user submits a bug report / feature request / feedback from Studio; studio-backend emails it to a Linear intake inbox, which becomes an issue whose description carries User Email: and Bug ID:
  2. A separate Worker receives /webhook/linear/feedback on the create event, verifies its own signing secret, checks the project, and acknowledges the reporter — so a report is confirmed as received rather than going silent until it closes
  3. That email greets them by the name on their Studio account, read from userName on their own row in renderboard.bugReports — studio-frontend sends it from the session it already holds when the report is filed
  4. The id of that acknowledgement is recorded on the reporter's own row in renderboard.bugReports, the collection studio-backend writes when the report is filed
  5. QA later moves that issue to a terminal state — Done or Canceled
  6. The same endpoint receives the update event, and the reporter's address is parsed out of the description and emailed via Resend — resolved copy for Done, declined copy for Canceled
  7. That email goes out as a reply inside the acknowledgement's thread, so the reporter reads both messages in one conversation instead of two unconnected emails
  8. The report is marked CLOSED in the same collection, so nobody has to close it by hand in the Bug Dashboard afterwards
  9. Any issue marked a duplicate of the closed one is looked up via the Linear API, and its reporter is notified too — threaded against their acknowledgement, greeted by their own name, and their report closed too
  10. Each email leaves a one-line comment on the ticket it went out from — 📨 Resolution email sent to \someone@example.com`.` — so a triager opening the issue can see what its reporter was told without reading the Worker's logs. That comment is the only thing either flow writes to Linear; the issue itself is never changed

This flow runs as its own Worker (feedback-worker/) with its own Linear webhook and signing secret, so it is fully isolated from the #auto-pr command flows above.

D) Fix a Sentry-Reported Bug (Linear, no repo argument)

  1. Sentry's Linear integration files an issue linking the Sentry issue — via the description or an attachment
  2. A developer posts #auto-pr with no repo — the target is resolved from the linked Sentry issue's project via SENTRY_PROJECT_TO_REPO
  3. CI fetches the Sentry issue and latest event, renders a stacktrace context block into the prompt, and runs the Sentry prompt variant
  4. The agent either fixes the bug or, when the root cause is in another service, makes no changes and reports a diagnosis back to Linear

Flow D resolves where the error surfaced, which is not always where the cause lives. An explicit repo always wins, so #auto-pr scenarix/other-repo is the retarget path when the diagnosis points elsewhere. Both outcomes — a PR, or no changes plus a diagnosis — are successes.

Omitting the repo only works in flow D. There is nothing to infer a target from on an ordinary Linear issue, so a bare #auto-pr there replies asking for a repo rather than guessing. Outside flow D both parts are required — the repo and instructions:

#auto-pr scenarix/studio-frontend the answer pane auto-scrolls while the user is reading

Instructions are optional only in flow D, where the stacktrace carries the task.

Architecture

Trigger source (Linear issue comment OR GitHub PR comment)
       │
       ▼
Cloudflare Worker (/webhook/linear, /webhook/github)
       │
       ├── Verify HMAC signature + allowlists
       ├── Parse #auto-pr instruction text
       ▼
GitHub Actions (in scenarix/auto-pr via repository_dispatch)
       │
       ├── Generate GitHub App token scoped to target repo
       ├── Configure AWS credentials via OIDC
       ├── Clone target repo
       ├── Fetch context from source (Linear issue details, etc.)
       ├── Run Aider with AWS Bedrock (Claude)
       ├── New PR mode: create branch + open PR
       ├── Iteration mode: update existing PR branch
       └── Report result to source (Linear or PR comment)

Flow C runs as a separate Worker on its own webhook and secret. Both events it listens for arrive at the same URL, so the body is verified once and then routed by action:

Linear issue created  (action: "create")
       │
       ▼
Feedback Worker (/webhook/linear/feedback)
       │
       ├── Verify HMAC (LINEAR_FEEDBACK_WEBHOOK_SECRET)
       ├── Require ACK_ON_CREATE = "true"
       ├── Require membership of a NOTIFY_REQUIRED_PROJECTS project
       ├── Skip issues labelled with a NOTIFY_SKIP_LABELS label
       ├── Parse the reporter's address out of the description
       ├── renderboard.bugReports: userName, for the greeting
       │     └── absent (filed logged out, or filed by hand) falls back
       │         to the address-derived guess
       ▼
Resend  ──►  reporter's inbox   ("We received your feedback")
       │
       ▼
renderboard.bugReports  ──►  record the sent email's id on the reporter's row
                             (plus the Linear issue id/key/url, if absent)
       │
       ▼
Linear API  ──►  comment on the ticket: which email went where
                 (NOTIFY_LINEAR_COMMENT; a refused write costs only the comment)
Linear issue moved to Done or Canceled  (action: "update")
       │
       ▼
Feedback Worker (/webhook/linear/feedback)
       │
       ├── Verify HMAC (LINEAR_FEEDBACK_WEBHOOK_SECRET)
       ├── Require a status change into a NOTIFY_STATES state
       │     └── "Done" -> resolved copy, "Canceled" -> declined copy
       ├── Require membership of a NOTIFY_REQUIRED_PROJECTS project
       ├── Skip issues labelled with a NOTIFY_SKIP_LABELS label
       ├── renderboard.bugReports: mark the report CLOSED
       │     └── runs for a cancellation that emails nobody, too
       ├── Require NOTIFY_CANCEL = "true" for the Canceled half only
       ├── Parse the reporter's address out of the description
       │     └── re-read via Linear API when truncated/absent
       ├── Linear API: collect issues marked a duplicate of this one
       │     └── project-gate and label-gate each, parse its own reporter
       ├── Dedupe by issue id and by recipient address
       ├── renderboard.bugReports: find that reporter's acknowledgement
       │     └── Resend GET /emails/{id} for the Message-ID it was sent with
       │     └── that row's userName is the greeting, at no extra read
       ▼
Resend  ──►  each reporter's inbox   (a reply in their own acknowledgement's
                                      thread; sends are independent, one
                                      failure does not block the others)
       │
       ▼
renderboard.bugReports  ──►  record which outcome they were told
       │
       ▼
Linear API  ──►  comment on each reporter's OWN ticket, duplicates included
                 (NOTIFY_LINEAR_COMMENT)

Neither flow changes the issue itself — no status changes, no labels, no assignees. The one thing they write to Linear is a comment per email, and it is never read back: the repeat-send guard is updatedFrom.stateId, not anything on the ticket. See The Linear comment. Every other write goes to renderboard.bugReports, and every one of them is optional: with MONGODB_URI unset both flows email exactly as they did before, just unthreaded, with nobody's report closed, and greeting reporters by their address.

Webhook payloads carry no downward links — children, duplicates and relations are always null, even on the terminal event — so duplicates are only reachable through the API. This matters because a duplicate sits in the Duplicate workflow state, which is a distinct state type and therefore never satisfies the trigger: without this lookup those reporters would never be notified by anything.

Project Structure

auto-pr/
├── .github/workflows/
│   ├── ai-worker.yml              # Central orchestrator (thin — calls scripts)
│   ├── ai-worker-cc.yml           # Claude Code orchestrator
│   ├── ai-review-worker-cc.yml    # Centralized PR review workflow
│   └── deploy-worker.yml          # CI/CD for Cloudflare Worker
│   ├── deploy-feedback-worker.yml # CI/CD for the feedback Worker
├── worker/
│   ├── src/
│   │   ├── index.js               # Router (maps routes to handlers)
│   │   ├── handlers/
│   │   │   ├── linear.js          # Linear webhook handler
│   │   │   └── github.js          # GitHub webhook handler (review)
│   │   └── lib/
│   │       ├── crypto.js          # HMAC verification (shared with feedback-worker)
│   │       ├── dispatch.js        # GitHub dispatch + acknowledgement
│   │       ├── sentry-repo.js     # Sentry link → project slug → repo
│   │       └── utils.js           # json(), log() (shared with feedback-worker)
│   ├── test/                      # node:test unit tests
│   ├── wrangler.toml              # Worker config + allowed actors/repos
│   └── package.json
├── feedback-worker/               # Flow C — acknowledge, then notify on status change
│   ├── src/
│   │   ├── index.js               # Router (/webhook/linear/feedback)
│   │   ├── handlers/
│   │   │   ├── linear-feedback.js # Verify once, route create vs update
│   │   │   ├── linear-created.js  # Acknowledgement trigger logic
│   │   │   └── linear-status.js   # Terminal-transition trigger logic
│   │   └── lib/
│   │       ├── verify-webhook.js  # HMAC + replay window + payload capture
│   │       ├── audience.js        # Project + skip-label gates, shared by both flows
│   │       ├── constants.js       # Log reasons, outcomes, thread reasons, bug status
│   │       ├── linear-api.js      # Issue details + duplicates; the comment mutation
│   │       ├── linear-comment.js  # "email sent" comment copy + the toggle
│   │       ├── mongo.js           # renderboard.bugReports, one connection per event
│   │       ├── bug-report-store.js# Ack id, Linear join, CLOSED, notified outcome, name
│   │       ├── reporter-name.js   # The stored userName -> the reporter's first name
│   │       ├── resend-api.js      # GET /emails/{id} for the sent Message-ID
│   │       ├── notify.js          # Resend send + threading headers
│   │       ├── parse-report.js    # Email / first name / summary / Bug ID extraction
│   │       └── templates.js       # HTML + text bodies
│   ├── test/
│   │   └── helpers/               # In-memory collection fakes — tests need no database
│   ├── wrangler.toml              # Project + label gates, email switches, from-address
│   └── package.json
├── scripts/
│   ├── run-aider.sh               # Aider wrapper (reads prompt template)
│   ├── run-claude-code.sh         # Claude Code wrapper
│   ├── fetch-context.sh           # Source-aware context resolution + Sentry enrichment
│   ├── create-pr.sh               # PR creation with formatted body
│   ├── report-result.sh           # Source-aware result reporting
│   └── lib/
│       └── render-sentry-context.mjs  # Sentry JSON → markdown stacktrace block
├── coolify-preview-infra/
│   ├── README.md                  # Coolify setup, DNS, env vars, secrets
│   └── terraform/                 # EC2 + Coolify infrastructure
│       ├── main.tf                # EC2 instance, security group, EIP, key pair
│       ├── variables.tf
│       └── outputs.tf
├── prompts/
│   ├── 2026-02-23-v1.md           # Default prompt template (versioned by date)
│   └── 2026-08-07-sentry-v1.md    # Sentry variant: scope discipline + no masking
├── .gitignore
└── README.md

Setup

1. Create a GitHub App

  1. Navigate to Scenarix org settings → Developer settings → GitHub Apps → New
  2. Set permissions:
  3. Repository: Contents (read + write)
  4. Repository: Pull requests (read + write)
  5. Repository: Metadata (read)
  6. Events: none needed
  7. Generate and download the private key (.pem file)
  8. Note the App ID
  9. Install the app on the Scenarix organization (all repos or specific ones)

2. Create a Linear API Key

  1. Linear Settings → API → Create new key
  2. Scopes: Read + Create Comments
  3. Save the key (needed as both a Worker secret and a GitHub Actions secret)

3. Set Up AWS Bedrock OIDC Role

  1. In AWS IAM, create an OIDC identity provider for token.actions.githubusercontent.com (if not already done)
  2. Create an IAM role (e.g. auto-pr-bedrock-role) with:
  3. Trust policy: allow sts:AssumeRoleWithWebIdentity from the OIDC provider, restricted to repo:scenarix/auto-pr:*
  4. Permission policy:
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": [
            "bedrock:InvokeModel",
            "bedrock:InvokeModelWithResponseStream",
            "bedrock:ListFoundationModels",
            "bedrock:ListInferenceProfiles"
          ],
          "Resource": "*"
        }
      ]
    }
    
  5. Note the role ARN

4. Configure GitHub Actions Secrets and Variables

In the scenarix/auto-pr repo settings: If some of these are org secrets, need not set them in the repo again.

Type Name Value
Secret AUTO_PR_BOT_APP_PRIVATE_KEY GitHub App private key PEM
Secret LINEAR_API_KEY Linear API key (used by AI workflow + Worker secret sync) — needs write scope: Flows A/B post result comments. Also Flow C's fallback when LINEAR_FEEDBACK_API_KEY is unset.
Secret LINEAR_FEEDBACK_API_KEY Flow C only. The "Bug Reporter" OAuth app's user token (lin_oauth_…, scopes read + write), so the per-email ticket comment posts as the app rather than a person. Optional: the feedback deploy falls back to LINEAR_API_KEY with a warning in the Actions log.
Secret AWS_BEDROCK_ROLE_TO_ASSUME AWS role ARN
Secret CLOUDFLARE_API_TOKEN Cloudflare API token
Secret CLOUDFLARE_ACCOUNT_ID Cloudflare account ID
Secret LINEAR_WEBHOOK_SECRET Linear webhook signing secret (synced to Worker secret)
Secret AUTO_PR_GH_WEBHOOK_SECRET GitHub webhook signing secret (synced to Worker secret GH_WEBHOOK_SECRET)
Secret LINEAR_FEEDBACK_WEBHOOK_SECRET Signing secret for the status-change webhook (flow C) — distinct from LINEAR_WEBHOOK_SECRET
Secret RESEND_API_KEY Resend API key used to email reporters (flow C)
Secret SENTRY_AUTH_TOKEN Sentry API token, scopes org:read project:read event:read (flow D only)
Secret LINEAR_AUTO_PR_API_KEY Flows A/B/D. The "Auto PR" Linear OAuth app's user token (lin_oauth_…, scopes read + write), so result and acknowledgement comments post as the app. Optional: the AI workflows and the command Worker deploy fall back to LINEAR_API_KEY.
Variable AUTO_PR_BOT_APP_ID GitHub App ID
Variable AWS_REGION us-east-1
Variable BEDROCK_MODEL e.g. bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0

Check AWS Bedrock console for the exact model ID or inference profile ID for your region.

5. Set Up Coolify Preview Infrastructure

See coolify-preview-infra/README.md for Terraform setup, Coolify configuration, and DNS. Preview deployments are configured directly in the Coolify dashboard via automatic preview deployments.

6. Deploy the Cloudflare Worker

Push to main with changes under worker/** (or rerun deploy-worker.yml) and CI will:

  1. Install Worker dependencies
  2. Sync Worker runtime secrets from GitHub Actions secrets:
  3. LINEAR_WEBHOOK_SECRET
  4. GH_WEBHOOK_SECRET (from GitHub secret AUTO_PR_GH_WEBHOOK_SECRET)
  5. AUTO_PR_BOT_APP_ID
  6. AUTO_PR_BOT_APP_PRIVATE_KEY
  7. LINEAR_API_KEY
  8. SENTRY_AUTH_TOKEN
  9. Deploy Worker code via wrangler deploy

No manual wrangler secret put step is needed when using this CI path.

Note the deployed Worker URL (e.g. https://auto-pr-worker.<subdomain>.workers.dev).

7. Configure the Linear Webhook

  1. Linear Settings → Webhooks → Create webhook
  2. URL: https://auto-pr-worker.<subdomain>.workers.dev/webhook/linear
  3. Events: select "Issue comments"
  4. Copy the signing secret → store it in GitHub Actions secret LINEAR_WEBHOOK_SECRET (step 4), then rerun deploy-worker.yml
  5. Test with "Send test event" and check Worker logs

8. Configure the GitHub Webhook (PR Iteration + Review)

On each target repository where you want PR iteration and centralized PR review:

  1. Settings → Webhooks → Add webhook
  2. Payload URL: https://auto-pr-worker.<subdomain>.workers.dev/webhook/github
  3. Content type: application/json
  4. Secret: set a strong value and store the same value in scenarix/auto-pr secret AUTO_PR_GH_WEBHOOK_SECRET
  5. Events: select Issue comments and Pull requests
  6. Save and test with a PR comment containing #auto-pr ...

9. Deploy the Feedback Worker and Its Webhook (Flow C)

This is a second Cloudflare Worker with its own Linear webhook and signing secret. It deploys independently of worker/ — the two workflows are separated by their paths: filters — and reuses the existing CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID, so no new Cloudflare credentials are needed. wrangler deploy creates the Worker on first run from the name in feedback-worker/wrangler.toml; there is no manual dashboard step.

  1. Add GitHub Actions secrets LINEAR_FEEDBACK_WEBHOOK_SECRET and RESEND_API_KEY (step 4). Use the Resend key from the same account studio-backend sends with, so the address in NOTIFY_FROM_EMAIL is already a verified sender.
  2. Add RESEND_READ_API_KEY and MONGODB_URI — both optional: threading, auto-close and greeting reporters by their account name. See Threading the resolution into the acknowledgement for what each one is and why the read key has to be a second, full_access key. The deploy step guards both, so it succeeds before either exists: without them the three emails send exactly as before, just unthreaded, with nobody's report closed, and greeting reporters by their address.
  3. Push to main with changes under feedback-worker/** (or rerun deploy-feedback-worker.yml). CI installs deps, runs the Worker tests, syncs secrets, and deploys.
  4. Linear Settings → Webhooks → Create webhook
  5. URL: https://auto-pr-feedback-worker.<subdomain>.workers.dev/webhook/linear/feedback
  6. Resource types: Issues only — other resource types deliver payloads with no data.state, which are rejected but still cost an invocation
  7. Team: the team that owns the intake project
  8. Copy that webhook's signing secret into LINEAR_FEEDBACK_WEBHOOK_SECRET and rerun deploy-feedback-worker.yml.
  9. Create the Skip notify label in Linear — the opt-out named by NOTIFY_SKIP_LABELS, which cannot be applied to any issue until the label exists. Do this before the webhook goes live, so a ticket you want kept quiet can be labelled ahead of being closed.
  10. Move a test issue in the intake project to Done (or Canceled), then check npx wrangler tail from feedback-worker/. Expect one notified line per reporter — the issue's own, plus one for each duplicate of it — each carrying the resolved outcome, plus a comment_posted line per email and a report_closed line. The comment is also visible on the ticket itself, which is the quickest check that the key can write. Add Skip notify to a second issue and close it to confirm the opposite: a single skip_label_present line and no email — but still a report_closed, because closing the report is not an email.
  11. Watch the first real intake ticket arrive, again via wrangler tail. Expect one notified line with outcome: "acknowledged" — from that point every new report in the intake project is acknowledged, so this is the step that changes what reporters receive. Set ACK_ON_CREATE = "false" and redeploy to stop it.

Each of the three emails has its own switch, so any one can be stopped with an env change and a redeploy: ACK_ON_CREATE for the acknowledgement, NOTIFY_CANCEL for the declined email, NOTIFY_THREAD_REPLIES to unthread the resolution without silencing it, NOTIFY_LINEAR_COMMENT to stop commenting on tickets without touching the emails, and NOTIFY_SKIP_LABELS for a single ticket with no deploy at all. The Done email is the one that always sends.

Until step 5 completes the Worker answers 500 Server misconfigured, because the signing secret does not exist yet. That ordering is unavoidable: the webhook URL only exists after the first deploy, and Linear only issues the signing secret when the webhook is created.

CI runs this Worker on Node 22, unlike the other two workflows: wrangler 4 declares engines.node >= 22, and wrangler 4 is what makes the mongodb driver work at all — under the workerd bundled with wrangler 3 the driver bundles fine and then times out on every connection, and that runtime silently caps compatibility_date at 2025-07-18 whatever wrangler.toml says.

The Worker's LINEAR_API_KEY is synced from the GitHub secret LINEAR_FEEDBACK_API_KEY: the "Bug Reporter" OAuth app's user token (Linear → Settings → API → Applications → Bug Reporter → Create token, scopes read + write), so the comment appears from the app rather than whoever generated a key. When that secret is unset the deploy falls back to the shared LINEAR_API_KEY and says so in the Actions log. Both token shapes are accepted: lin_oauth_… is sent as Bearer, lin_api_… bare. It needs permission to create comments, and that is the only write it makes: it reads the issue description when a payload is truncated, reads the duplicate graph, and comments on the ticket after each email. A read-only key still works — every send logs a warning that Linear declined the comment and the email goes out unchanged — so pairing a read-only key with NOTIFY_LINEAR_COMMENT = "false" is a valid setup, just a quieter one. The comment is attributed to whoever owns the key.

Configuration

Allowed Actors and Repos

These are configured in worker/wrangler.toml as [vars] — update via PR, no manual secret rotation:

[vars]
ALLOWED_ACTOR_EMAILS = '["developer@scenarix.ai","teammate@scenarix.ai"]'
ALLOWED_GITHUB_LOGINS = '["developer-gh","teammate-gh"]'
# Existing command/code-change allowlist name retained for compatibility
ALLOWED_REPOS = '["scenarix/frontend-app","scenarix/api"]'
ALLOWED_REPOS_REVIEW = '["scenarix/frontend-app"]'
# Sentry project slug -> repo, for `#auto-pr` with no explicit repo
SENTRY_PROJECT_TO_REPO = '{"frontend-app":"scenarix/frontend-app"}'

ALLOWED_GITHUB_LOGINS is used for GitHub PR-comment iteration (/webhook/github) and must contain lowercase GitHub usernames.

ALLOWED_REPOS controls where #auto-pr command dispatching is allowed (Linear + GitHub issue comments).

ALLOWED_REPOS_REVIEW controls where pull-request auto-review dispatching is allowed.

SENTRY_PROJECT_TO_REPO maps a Sentry project slug to a repo for flow D. Keys are slugs, never numeric project ids — Sentry's UI copies URLs containing a numeric id, which the Worker exchanges for a slug via the Sentry API. A repo here is not thereby trusted: an auto-resolved target still has to appear in ALLOWED_REPOS before anything is dispatched.

Branch policy for auto-review: - triggered for branch -> staging - triggered for branch -> main - not triggered for staging -> main

Reporter Notifications (Flow C)

Configured in feedback-worker/wrangler.toml as [vars], the same convention worker/wrangler.toml uses for its allowlists — update via PR, no secret rotation:

[vars]
#   18c1d679-3541-4130-8e80-e38a4733eba9 = "Studio Feedback" (PEA, the intake project)
NOTIFY_REQUIRED_PROJECTS = '["18c1d679-3541-4130-8e80-e38a4733eba9"]'
NOTIFY_SKIP_LABELS = '["Skip notify"]'
ACK_ON_CREATE = "true"
NOTIFY_CANCEL = "false"
NOTIFY_THREAD_REPLIES = "true"
NOTIFY_LINEAR_COMMENT = "true"
# Matches SharedConstants.MAILER_FROM_EMAIL in studio-backend.
NOTIFY_FROM_EMAIL = 'Studio Jadu <noreply@studiojadu.com>'
# ECHO_ONLY = "true"   # log the intended send without calling Resend

Both list vars are JSON arrays parsed by parseJsonArray, so a name can be added or changed without touching code — edit the toml and deploy. There is no .env here: wrangler does not turn .env entries into Worker bindings. .dev.vars does override these, but only for wrangler dev (see Test the feedback Worker against real Linear events).

NOTIFY_REQUIRED_PROJECTS gates which issues notify their reporter — the issue must sit in one of these Linear projects, matched on project id or name, case-insensitive. Widening the audience is a one-line PR.

Projects rather than labels, because the intake inbox files reports straight into the project: project is set the moment the issue exists (208 of 209 intake tickets had it at creation), whereas labels are applied by hand hours to days later and often never. Set by id because this project has already been set to Studio Feedback`

NOTIFY_SKIP_LABELS is the manual opt-out, and it overrides the project gate: adding one of these labels in Linear stops the email for that one ticket, with no deploy at all — the label is read off the issue on each event. Use it for tickets the team filed itself, ones a human has already answered directly, and anything closed while testing. Matched on label name, case-insensitive — the name is what you type into Linear, so renaming the label there means updating this list to match. The label must exist in the team before it can be applied.

Acknowledgement on creation

ACK_ON_CREATE emails the reporter the moment Linear creates the ticket, so a report is confirmed as received rather than going silent until it closes — which can be weeks. Set it to anything other than "true" to turn acknowledgements off with a deploy; terminal-state emails are unaffected either way.

An acknowledgement is sent only when all of the following hold:

Check Why
ACK_ON_CREATE is "true" The kill switch above
type is Issue and action is create See below
Issue is in a NOTIFY_REQUIRED_PROJECTS project The same audience gate, reusing the same var
Issue carries no NOTIFY_SKIP_LABELS label The same opt-out — rarely fires here, since a new issue is unlabelled
Description yields a User Email: value Hand-filed tickets carry no address, so this is normal input

The create action is the whole trigger — no status check. create fires exactly once per issue (46 updates vs 3 creates across the captured deliveries, with no create ever delivered twice), so a PM moving a ticket Backlog → Todo → Backlog emits update events and can never re-acknowledge. Checking state.name would guard nothing, and counting activities does not work either: Linear history accumulates after creation, so only 2 of 87 currently-Backlog intake tickets still have a single entry.

This path costs zero Linear API calls: the create payload carries the project, labels and full description. The trade-off is that a Linear-side redelivery of a create would email the reporter twice.

After the send it records the acknowledgement's id against the reporter's row in renderboard.bugReports, which is what lets the resolution email reply inside this thread weeks later — see Threading the resolution into the acknowledgement. That write happens after the email, never before it, so a database problem costs the thread and not the acknowledgement.

Two consequences worth knowing:

  • Acknowledgements roughly triple this Worker's send volume — intake runs ~5.7 tickets per day, peaking at 17.
  • A ticket filed by hand into the intake project acknowledges whoever is named in its description, since there is no bot check. This affected 3 of 435 historical tickets, all hand-filed test tickets. NOTIFY_SKIP_LABELS cannot prevent it: a create leaves no window to apply the label first.

Terminal-state notifications

An email is sent only when all of the following hold; otherwise the Worker logs the reason and returns ignored:

Check Why
type is Issue and action is update Issue creation routes to the acknowledgement path above; comments are ignored
updatedFrom.stateId is present Proves the status changed in this event, so later edits while the issue rests in a terminal state do not re-notify
data.state.name is a key of NOTIFY_STATES Matched on name, not state.type, so a second completed state added later cannot over-match
Issue is in a NOTIFY_REQUIRED_PROJECTS project The audience gate above
Issue carries no NOTIFY_SKIP_LABELS label The manual opt-out above
NOTIFY_CANCEL is "true", for Canceled only The kill switch below; Done is unaffected
Description yields a User Email: value Bug reporting is unauthenticated, so the address is optional and often absent

updatedFrom.stateId is the whole repeat-send guard. An earlier version also wrote a hidden auto-pr-notified marker comment and read it back before sending; that read was removed, and the visible comment each email now leaves is not a replacement for it — see The Linear comment. Across 59 captured deliveries no terminal transition was ever delivered twice. The residual risk is narrow: moving an issue out of Done and back in emails its reporter again, and NOTIFY_SKIP_LABELS is the manual stop for that.

The project, skip-label and address checks are applied independently to each duplicate, so a duplicate outside the notified project is not pulled in just because the issue it duplicates is inside it, and labelling a duplicate silences only its own email. A closed issue with no parseable address still notifies its duplicates.

Labels arrive on the webhook payload (confirmed on all 41 captured deliveries), so the opt-out normally costs no API call. When a truncated payload drops both the description and the labels, the fallback query returns them together and the check re-runs on that authoritative list.

Which states notify

NOTIFY_STATES in src/handlers/linear-status.js maps each notifying state name to the copy it sends. The keys must match the Linear workflow exactly — note the PEA team spells it Canceled with one L:

State Type Outcome
Done completed resolved
Canceled canceled declined — only when NOTIFY_CANCEL is "true"

Every other PEA state (Todo, In Progress, In Review, QA, Blocked, Backlog, Duplicate) logs not_a_notify_state and returns ignored. Duplicate is excluded deliberately: a duplicate's reporter is notified from the issue it duplicates, so also notifying on its own transition into Duplicate would send the same person two emails.

NOTIFY_CANCEL gates the Canceled half on its own, so the "we're not building this" email — the one most likely to need rewording or pausing — can be stopped without also silencing the Done emails. Set it to anything other than "true" and a cancelled report logs cancel_disabled and sends nothing; Done is unaffected. There is deliberately no toggle for Done: telling someone their report was fixed is always worth sending.

Like ACK_ON_CREATE, it fails closed — a var missing from wrangler.toml means off, so the email can never start sending because of a config gap. It is checked last, after the project and skip-label gates, so a cancel_disabled line always means one email was genuinely suppressed by the toggle rather than an unrelated issue happening to be cancelled.

Duplicates

"B is a duplicate of A" puts the edge on B's relations and A's inverseRelations. The terminal transition fires on A, so inverseRelations is the side read — relations on A is empty.

Two dedupe layers, both needed in practice:

  • By issue id — an issue can be linked twice (e.g. both a sub-issue and a duplicate), and would otherwise be notified once per edge.
  • By recipient address, case-insensitively — one person who filed the same bug twice gets one email, not one per issue.

Only the duplicate relation cascades. Sub-issues deliberately do not: a sub-issue is often distinct work, and one still in progress when its parent closes should not be told its report is resolved.

Each send is isolated. A bounce or provider rejection for one recipient is logged and the remaining reporters are still notified.

Threading the resolution into the acknowledgement

A reporter who files a bug now gets two emails weeks apart. NOTIFY_THREAD_REPLIES makes the second one a reply to the first, so both sit in one conversation: subject Re: We received your feedback, with In-Reply-To and References set to the acknowledgement's Message-ID. Both headers, not one — clients disagree about which they honour, and Apple Mail wants References in particular.

That Message-ID has to be read back from Resend, which is the whole reason for the second API key:

  • POST /emails answers with { id } and nothing else, and Resend sends through SES, which assigns the Message-ID itself. Supplying one at send time is pointless — it is overwritten.
  • GET /emails/{id} returns it, but refuses a sending key with 401 restricted_api_key, and Resend offers only full_access and sending_access — there is no read-only tier.
  • So RESEND_READ_API_KEY is a second, full-access key used for that one GET and nothing else, kept separate from the sending key rather than widening it.
  • The alternative was the email.sent webhook: a second public endpoint, its own Svix signature verifier, another secret, and a new failure mode where one dropped delivery loses the id forever.

The Re: subject comes back from the same GET rather than being re-derived from templates.js. The acknowledgement copy has already been reworded once, and a re-derived subject would silently stop matching the email it claims to answer the next time someone edits it.

X-Entity-Ref-ID carries the Linear issue id on every email. Gmail collapses messages by subject alone, so without it two unrelated reports from the same person would be folded into one conversation — one value per issue keeps them apart.

Which acknowledgement to reply to is looked up per recipient, not per event: a duplicate's reporter must be threaded against the acknowledgement of their report, or their resolution lands in a stranger's conversation.

Threading is best-effort. Every path that finds no thread still sends the email, as the standalone message this Worker sent before — the reason is carried on the notified log line so a reporter with two unconnected emails can be explained without a repro:

threadReason Meaning
threading_disabled NOTIFY_THREAD_REPLIES is not "true" — the rollback. Nothing is read from Resend
no_store No MONGODB_URI, so there is nowhere the acknowledgement could have been recorded
no_bug_id The description carries no Bug ID: — a ticket filed by hand, not a Studio report
no_bug_report Parsed fine, but no report matches either key
no_ack_thread The report exists and was never acknowledged — see below
thread_lookup_failed Resend would not return the Message-ID (404, or a scheduled/failed email)
store_unavailable Mongo connected and then threw on the query

Every ticket open before this deploys will log no_ack_thread and send unthreaded. There is no acknowledgement id recorded for them, and nothing can reconstruct one. Only the read side is gated by NOTIFY_THREAD_REPLIES, though — the acknowledgement records what a reply needs even while threading is off, so turning it on later threads every report acknowledged in the meantime.

NOTIFY_THREAD_REPLIES = "false" is the rollback: the resolution email goes back to being a standalone message with its own subject, while reports keep being closed.

Closing the report

Reaching Done or Canceled also sets bugStatus: "CLOSED" on the reporter's row, so nobody has to close the report by hand in the Bug Dashboard afterwards.

This is deliberately independent of the email, because closing a report is a state fix rather than a notification. It runs for a Canceled ticket while NOTIFY_CANCEL suppresses the declined email, for a ticket carrying Skip notify, and for a report whose description yields no address. A duplicate's report is closed too — from inside the notify loop, since a duplicate sits in the Duplicate state and its own transition never triggers anything.

The row is matched on linearIssueId or the Bug ID: from the description, whichever hits: reports filed before studio-backend began recording the Linear issue have only the second. The write is idempotent — a redelivery writes CLOSED over CLOSED — and the log line's changed says which happened, read from the document as it was before the write.

Fields on renderboard.bugReports, whose owner is studio-backend:

Field Written when Notes
ackEmailId, ackSentAt Acknowledgement sent Owned by this Worker; ackEmailId is what threading reads back
lastNotifiedOutcome, lastNotifiedAt Any of the three emails sent Owned here too. The only place the resolved/declined distinction survives — BugStatus has just OPEN and CLOSED
bugStatus Terminal transition Shared with studio-backend, which also writes it from the dashboard
linearIssueId, linearIssueIdentifier, linearIssueUrl Acknowledgement, only if absent Backfilled for intake-era reports; a join already written by studio-backend is never overwritten

Three things to know before turning MONGODB_URI on:

  • A report is never reopened. Moving a ticket out of Done and back into In Progress leaves its report CLOSED — the Worker only ever hears about transitions into a notifying state. Reopen it in the dashboard.
  • Add the index the lookup needs: db.bugReports.createIndex({ linearIssueId: 1 }).
  • Cloudflare Workers have no static egress IPs, so Atlas needs 0.0.0.0/0 in its access list. Scope the database user to readWrite on renderboard.bugReports and nothing else — that is the only collection touched.

ECHO_ONLY suppresses every write, so a dry run against real webhook traffic reads production and changes nothing. And nothing here can fail an event: an unreachable cluster logs report_not_closed with store_unavailable and the email still goes out, because a database problem must not become a webhook failure, a Linear redelivery, and a duplicate email.

The Linear comment

Every email that goes out leaves one line on the ticket, so the record of what a reporter was told lives where the person triaging them is already looking:

📨 Acknowledgement email sent to `shubham@scenarix.ai`.
📨 Resolution email sent to `shubham@scenarix.ai`, as a reply inside the acknowledgement's thread.
📨 Decline email sent to `priya@scenarix.ai`.
📨 Resolution email sent to `priya@scenarix.ai`, as a reply inside the acknowledgement's thread, because this ticket is a duplicate of PEA-2581.

The label names which of the three emails it was, since the ticket's current state does not say — a report can be reopened, and a Canceled ticket that was Done first has had two emails. The address is in backticks so Linear renders it as text rather than a live mailto: link.

Four things this deliberately does:

  • Posted after the send, never before. A comment claiming an email that failed to leave is worse than no comment. It is also the last thing either flow does, after the Mongo writes: the stored acknowledgement id is what makes the resolution threadable weeks later, and it matters more than the comment.
  • On the reporter's own ticket. A duplicate's reporter was emailed about their issue, so their comment lands on it — not on the ticket that happened to reach Done. That is also the only place it is any use: nobody watching PEA-2597 would otherwise learn its reporter had been written to.
  • Never read back. It is not a dedupe marker. What stops a repeat send is updatedFrom.stateId on the update event and create firing once per issue — see Terminal-state notifications. An earlier design did use a marker comment and read it back, which is why the docs used to ask for a write-scoped key; the read was removed, and this write is not a return to it.
  • Fail-open, like everything else here. Linear refusing the mutation — a read-only key answers success: false with HTTP 200 — logs a warning and nothing more. The email still went, the report is still closed, and the webhook still answers 200, because a non-2xx would have Linear redeliver the transition and re-email a reporter who already heard back.

NOTIFY_LINEAR_COMMENT gates it; unset means off, and wrangler.toml sets "true". Turning it off is the rollback, and it is worth setting NOTIFY_LINEAR_COMMENT=false in .dev.vars when running wrangler dev against real tickets: a bot comment on someone's issue is the one thing here that re-running cannot take back. ECHO_ONLY suppresses it too — a dry run leaves the ticket exactly as it found it.

One shape constraint worth knowing if the copy is ever edited: the comment must not begin #auto-pr <owner>/<repo>, because that is the command Worker's trigger (worker/src/handlers/linear.js) and both Workers listen to the same Linear workspace. Nothing this Worker writes starts with a mention at all, and a test pins that.

The reporter's name

The greeting is the reporter's own name, read from userName on their own row in renderboard.bugReports. It gets there when the report is filed: studio-frontend already holds the account's name — in the session it stores, and in the name claim on the access token — so it sends it with the report and studio-backend stores it. userName holds either "Shubham" or "Shubham Kaudewar", and the first word of it is what the email says.

This Worker deliberately does not read the auth database for it. That cluster restricts access by IP address, and a Cloudflare Worker has no static egress address to put on the list, so the name has to travel with the report instead.

Two sources, and the one used is on every notified and echo_only line as nameSource:

nameSource Read from When
report_user_name bugReports.userName The normal path, for a report filed from a signed-in session
email_local_part Nothing — the address local-part No MONGODB_URI, no userName on the report, or one that does not read like a name

That last row is what every email said before this existed: first.last@x.com yields First, + tags are stripped as routing metadata, and a local-part that is not name-shaped (support1@, a single initial) gives Hi there, rather than a token. It is also the fallback for a report filed while logged out, one filed by hand in Linear, one filed before userName was stored, and a userName holding an address, a company or one initial.

createBugReport is unauthenticated and does not validate its body, so userName is under the caller's control. Two things contain that: firstNameFromFullName greets with it only if it is shaped like a name — letters and name punctuation, at least two letters, no @ — and the templates escape it. Nothing else reads the field: it stays out of the Linear description and the dashboard.

Best-effort throughout, on the same rule as everything else here: an unreachable cluster logs a warning, the greeting drops to the address, and the email still goes out. Reads happen under ECHO_ONLY too, so a dry run reports the name the real send would have used.

That fail-open needs the driver's timeouts bounded, which is worth knowing because the first production deploy of the greeting proved it. waitUntil is cancelled about 30 seconds after the response and the driver's default serverSelectionTimeoutMS is also 30 seconds, so a cluster that hung instead of refusing spent the entire budget and the send was cancelled along with it: no greeting and no email. CONNECT_OPTIONS in src/lib/mongo.js caps selection and connect at 5s and a stalled socket at 10s, and a test pins the bound. A cluster that is unreachable now costs five seconds and a warning, which is what "optional" was always supposed to mean.

No read of its own on the terminal flow: the name comes off the same document the threading lookup already fetches. The acknowledgement flow does one read, on the key it would use anyway.

Email content

Placeholder Source
First name The reporter's Studio account — see The reporter's name. Falls back to the address local-part, and then to Hi there,
Feedback summary The Description: section of the report, stopping at the next known field label (Device Info:, Reported At:, …) so the quote never swallows the rest of the report. Falls back to the issue title when the section is missing or empty.

Both placeholders are filled per issue, so a duplicate's reporter is greeted by their own name and quoted their own words, not the resolved issue's.

The emails carry no images at all — no logo in the body, no tracking pixel. The brand belongs in the sender avatar, which is not part of the message; see The sender avatar.

Three bodies, selected by the outcome above and defined in src/lib/templates.js:

Outcome Subject Shape
acknowledged We received your feedback Confirms the report arrived, explains why feedback matters, quotes the report, promises an update on status change — no CTA
resolved We've resolved the issue you reported Quotes the report, thanks the reporter, invites them to retry, CTA to Studio Workbench
declined An update on the feedback you shared Quotes the report, explains the capability is not planned, confirms a person reviewed it — no CTA, no retry prompt

The two subjects in the last two rows are what sends unthreaded. When the resolution replies inside the acknowledgement's thread it takes the parent's subject with Re: instead — see Threading the resolution into the acknowledgement.

The declined copy omits the CTA on purpose: pointing someone at the product right after telling them the thing they asked for is not planned reads as "go try what we just said we won't build". The acknowledgement omits it because the reporter has just come from Studio and the email asks nothing of them. The CTA links to https://app.studiojadu.com.

Copy before the quoted report lives in intro, copy after it in body. intro accepts a string or an array of paragraphs — the acknowledgement uses several, the other two use one.

Both HTML and plain-text parts are sent; the plain-text part is built from the same copy with HTML entities (&mdash;, &rsquo;) resolved to real characters.

Prompt Template

The prompt sent to Aider lives in prompts/ as a versioned markdown file. The active template is set in scripts/run-aider.sh:

PROMPT_TEMPLATE="2026-02-23-v1.md"

To iterate on the prompt: create a new file (e.g. prompts/2026-03-15-v2.md), update the variable in run-aider.sh, and push. Previous versions stay in the repo as history.

The Claude Code path selects between two variants instead, via --prompt-variant:

Variant Template Used when
default 2026-02-23-v1.md Normal runs
sentry 2026-08-07-sentry-v1.md A Sentry context block was successfully injected

fetch-context.sh decides this: if Sentry enrichment produces a stacktrace block it emits prompt_variant=sentry, otherwise default. The Sentry variant adds scope discipline — decide whether the root cause is even in this repo, and never silence an error whose cause is upstream. An unknown variant is a hard error so a typo cannot silently run the wrong prompt.

Usage

Create a new PR from Linear

On any Linear issue, post:

#auto-pr scenarix/frontend-app Fix the null pointer in the login form

Result:

  1. Immediately acknowledge: "Working on it..."
  2. Clone the repo, run the AI agent, and open a PR
  3. Post back the PR link (or report failure)

Fix a Sentry bug from Linear

On a Linear issue that links a Sentry issue (Sentry's Linear integration adds this), post with no repo:

#auto-pr

Result:

  1. Worker looks for the Sentry link in the issue description, then in the issue's attachments (Sentry's Linear integration links via an attachment rather than pasting a URL), and resolves the target repo from the Sentry project, then acknowledges
  2. CI injects a rendered stacktrace into the prompt and runs the Sentry prompt variant
  3. Either a PR is opened, or — when the root cause is in another service — no changes are made and the agent's diagnosis is posted back to Linear

Both outcomes are successes. If the diagnosis names a different repo, retarget explicitly with #auto-pr scenarix/other-repo. Auto-resolution needs SENTRY_AUTH_TOKEN and an entry in both SENTRY_PROJECT_TO_REPO and ALLOWED_REPOS; without them the Worker replies asking for an explicit repo rather than guessing.

--aider is not supported for this flow: that workflow does no Sentry enrichment, so a bare #auto-pr --aider is refused with a comment rather than dispatched.

Iterate on an existing PR from GitHub

On an existing PR in an allowed repo, post:

#auto-pr Please rename this helper and add tests for the empty input case.

Result:

  1. Existing PR branch is checked out
  2. Follow-up commits are pushed to that same branch
  3. Status is posted as a PR comment

Centralized PR review from GitHub pull_request events

When a PR is opened/synchronized/reopened/ready_for_review in an allowed review repo:

  1. Worker validates signature, allowlist, and branch policy rules.
  2. Worker dispatches auto_pr_review_cc to scenarix/auto-pr.
  3. Worker posts an acknowledgement comment that review has been triggered.
  4. ai-review-worker-cc.yml runs Claude Code review and posts inline comments/verdict.

Trigger requirements (important)

  • Only PR comments containing #auto-pr are considered
  • Comment author must be in the configured allowlist
  • Repository must be in ALLOWED_REPOS for command workflows
  • Repository must be in ALLOWED_REPOS_REVIEW for pull_request auto-review workflows
  • Invalid/missing webhook signatures are rejected

Run Worker tests locally

From the repo root:

cd worker
npm ci

Run all Worker tests:

npm test

Run only the GitHub integration test file:

node --test ./test/github-handler.integration.test.js

The feedback Worker has its own suite:

cd feedback-worker
npm ci
npm test

Test the feedback Worker against real Linear events

wrangler secret put only sets deployed secrets, so local runs read feedback-worker/.dev.vars (gitignored). Start from the tracked template:

cd feedback-worker
cp .dev.vars.example .dev.vars   # then fill in the credentials
LINEAR_FEEDBACK_WEBHOOK_SECRET=lin_wh_...
LINEAR_API_KEY=lin_oauth_...          # Bug Reporter app token (GitHub: LINEAR_FEEDBACK_API_KEY); lin_api_... also accepted
RESEND_API_KEY=re_...
RESEND_READ_API_KEY=re_...            # optional; full access, for threading
MONGODB_URI=mongodb+srv://...         # optional; threading, auto-close, the reporter's name
ECHO_ONLY=true
NOTIFY_LINEAR_COMMENT=false           # recommended locally; see below

With ECHO_ONLY=true the Worker logs the intended recipient, first name and feedback summary instead of sending, which lets you exercise the Linear → Worker half before involving Resend. Remove the line to send real email.

The last two are optional — leave them out and threading, auto-close and the account-name greeting each log a reason and skip, which is also how you reproduce what production does before those secrets exist. Point MONGODB_URI at a cluster you are willing to write to: ECHO_ONLY suppresses the writes, but without it a local run closes real reports. With ECHO_ONLY=true the echoed line carries the firstName and nameSource it resolved, which is the way to check the greeting before sending anything.

NOTIFY_LINEAR_COMMENT=false is worth keeping in .dev.vars while testing against real tickets. ECHO_ONLY already suppresses the comment, but the moment you remove ECHO_ONLY to send a real email, every local run starts commenting on somebody's issue — and unlike a Mongo write under ECHO_ONLY, a bot comment cannot be undone by re-running. Turn it on deliberately, once, to confirm the key can write.

.dev.vars also overrides [vars] from wrangler.toml — wrangler dev prints Using vars defined in .dev.vars on start — so adding NOTIFY_SKIP_LABELS=["Something else"] there tries a renamed label locally without committing anything. The override applies to wrangler dev only; deploys always take the toml values.

cd feedback-worker && npx wrangler dev      # binds http://localhost:8787
ngrok http --url=<your-subdomain>.ngrok-free.dev 8787

Point a temporary Linear webhook (Issues only) at https://<ngrok-host>/webhook/linear/feedback, take a test issue in the intake project, and move it to Done (or Canceled, for the declined copy). The issue must be moved out of that state first — the trigger needs a transition into it, not an issue already sitting there.

To exercise the acknowledgement instead, email a report body to the intake address (feedback-from-studio-…@intake.linear.app) and let Linear create the ticket, which is what production does. The ticket must land in a NOTIFY_REQUIRED_PROJECTS project — an issue with no project logs project_not_matched. Then move it Backlog → Todo → Backlog and confirm no second acknowledgement: those are update events, and only create acknowledges.

Threading only shows up across both halves in order, so test it in one pass: file a report and confirm ackEmailId and ackSentAt appear on its row, then move that same ticket to Done and confirm the resolution arrives inside the same Gmail conversation and the row reads bugStatus: "CLOSED". Closing a ticket that was never acknowledged locally logs no_ack_thread and sends a standalone email — correct behaviour, not a failure.

Delete the temporary webhook when you finish. While it exists every close in the intake project reaches your laptop; once it is gone, closes are silent again until the production webhook is created. Anything you deliberately close during testing should carry the Skip notify label, so that its reporter is not emailed once production is live.

Two failure modes worth knowing, both of which look like silence rather than an error:

  • The tunnel must forward to 8787. Forwarding to port 80 yields 502 Bad Gateway with no Worker logs at all.
  • ngrok outlives wrangler dev. If the Worker stops while the tunnel stays up, Linear gets 502 and nothing is logged locally. Confirm the Worker is listening before testing: lsof -nP -iTCP:8787 -sTCP:LISTEN.

To exercise the send path without touching a real ticket or emailing a real person, POST a signed payload directly and use Resend's delivered@resend.dev address as the reporter. The signature is HMAC-SHA256(raw body, LINEAR_FEEDBACK_WEBHOOK_SECRET) in the Linear-Signature header; a synthetic issue id makes both Linear API lookups fail open, which is itself worth seeing.

Replay a delivery without touching the issue from either ngrok's inspector at http://localhost:4040 or Linear's webhook delivery log, which has a resend button.

Capture webhook payloads for analysis

To inspect exactly what Linear sends — including events the trigger filters out — set CAPTURE_PAYLOADS=true and run the capture script instead of wrangler dev directly:

cd feedback-worker
echo 'CAPTURE_PAYLOADS=true' >> .dev.vars
./scripts/capture-webhooks.sh              # wrangler dev on :8787, point ngrok here
./scripts/capture-webhooks.sh --remote     # or tail the deployed Worker
PORT=8799 ./scripts/capture-webhooks.sh    # different port

Workers have no filesystem, so the Worker logs each payload as one line of JSON and the script tees that stream to two gitignored files under feedback-worker/logs/:

File Contents
logs/webhooks.jsonl one JSON object per event — full payload plus request headers, append-only
logs/webhooks.log the raw log stream, everything the Worker emitted

Analyse with jq:

jq . logs/webhooks.jsonl | less                                       # browse full events
jq -r '"\(.issue)  \(.payload.data.state.name)"' logs/webhooks.jsonl  # what states arrived
jq 'select(.payload.data.state.name == "Done") | .payload.updatedFrom' logs/webhooks.jsonl
jq -r '.payload.data.description' logs/webhooks.jsonl                 # check parseability

Capture happens after signature verification, so unsigned requests are rejected without being logged and cannot inject log content. Linear-Signature, Authorization, and Cookie headers are stripped. Leave CAPTURE_PAYLOADS unset in production — payloads carry reporter email addresses.

Agent selection flags

By default, Auto-PR dispatches to the Claude Code workflow.

  • --cc: explicitly use Claude Code workflow
  • --aider: override to use Aider workflow

Flags can be included in either Linear or GitHub instructions and are stripped before sending the final instruction text to the agent.

Examples:

#auto-pr scenarix/frontend-app --aider fix flaky login test and update docs
#auto-pr --cc please refactor this helper and add coverage for empty input

Extensibility

The system is designed to be source-agnostic. Each trigger source gets its own route in the Worker and its own blocks in the scripts:

Component Linear Adding a new source
Worker worker/src/handlers/linear.js, worker/src/handlers/github.js, feedback-worker/src/handlers/linear-{feedback,created,status}.js Add worker/src/handlers/slack.js + one line in index.js router
Context fetch scripts/fetch-context.sh Add an elif [[ "$SOURCE" == "slack" ]] block
Result reporting scripts/report-result.sh Add a "slack" case
PR creation scripts/create-pr.sh No changes needed (fully generic)
Workflow ai-worker.yml No changes needed (calls scripts via env vars)

The client_payload.source field tells the workflow where the request came from. The scripts/fetch-context.sh script normalizes any source into generic outputs (identifier, title, description, url) that the core pipeline consumes.