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 returnsnot_a_commandand 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)¶
- A developer posts on a Linear issue:
#auto-pr scenarix/frontend-app Fix the null pointer in the login form - Worker receives
/webhook/linear, verifies signature and allowlist, then dispatches to GitHub Actions - Central workflow clones target repo, runs Aider, creates branch/commit, opens a PR
- Result is reported back to Linear
B) Iterate on an Existing PR (GitHub)¶
- A developer comments on a PR:
#auto-pr Please handle empty input and update tests - Worker receives
/webhook/github, verifies signature and allowlist, then dispatches to GitHub Actions - Central workflow checks out that PR head branch, runs Aider, and pushes follow-up commits to the same branch
- Result is posted as a PR comment
C) Notify the Reporter on Status Change (Linear)¶
- A user submits a bug report / feature request / feedback from Studio;
studio-backendemails it to a Linear intake inbox, which becomes an issue whose description carriesUser Email:andBug ID: - A separate Worker receives
/webhook/linear/feedbackon thecreateevent, 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 - That email greets them by the name on their Studio account, read from
userNameon their own row inrenderboard.bugReports—studio-frontendsends it from the session it already holds when the report is filed - The id of that acknowledgement is recorded on the reporter's own row in
renderboard.bugReports, the collectionstudio-backendwrites when the report is filed - QA later moves that issue to a terminal state — Done or Canceled
- The same endpoint receives the
updateevent, and the reporter's address is parsed out of the description and emailed via Resend — resolved copy for Done, declined copy for Canceled - 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
- The report is marked
CLOSEDin the same collection, so nobody has to close it by hand in the Bug Dashboard afterwards - 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
- 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)¶
- Sentry's Linear integration files an issue linking the Sentry issue — via the description or an attachment
- A developer posts
#auto-prwith no repo — the target is resolved from the linked Sentry issue's project viaSENTRY_PROJECT_TO_REPO - CI fetches the Sentry issue and latest event, renders a stacktrace context block into the prompt, and runs the Sentry prompt variant
- 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¶
- Navigate to Scenarix org settings → Developer settings → GitHub Apps → New
- Set permissions:
- Repository: Contents (read + write)
- Repository: Pull requests (read + write)
- Repository: Metadata (read)
- Events: none needed
- Generate and download the private key (.pem file)
- Note the App ID
- Install the app on the Scenarix organization (all repos or specific ones)
2. Create a Linear API Key¶
- Linear Settings → API → Create new key
- Scopes: Read + Create Comments
- Save the key (needed as both a Worker secret and a GitHub Actions secret)
3. Set Up AWS Bedrock OIDC Role¶
- In AWS IAM, create an OIDC identity provider for
token.actions.githubusercontent.com(if not already done) - Create an IAM role (e.g.
auto-pr-bedrock-role) with: - Trust policy: allow
sts:AssumeRoleWithWebIdentityfrom the OIDC provider, restricted torepo:scenarix/auto-pr:* - Permission policy:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:ListFoundationModels", "bedrock:ListInferenceProfiles" ], "Resource": "*" } ] } - 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:
- Install Worker dependencies
- Sync Worker runtime secrets from GitHub Actions secrets:
LINEAR_WEBHOOK_SECRETGH_WEBHOOK_SECRET(from GitHub secretAUTO_PR_GH_WEBHOOK_SECRET)AUTO_PR_BOT_APP_IDAUTO_PR_BOT_APP_PRIVATE_KEYLINEAR_API_KEYSENTRY_AUTH_TOKEN- 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¶
- Linear Settings → Webhooks → Create webhook
- URL:
https://auto-pr-worker.<subdomain>.workers.dev/webhook/linear - Events: select "Issue comments"
- Copy the signing secret → store it in GitHub Actions secret
LINEAR_WEBHOOK_SECRET(step 4), then rerundeploy-worker.yml - 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:
- Settings → Webhooks → Add webhook
- Payload URL:
https://auto-pr-worker.<subdomain>.workers.dev/webhook/github - Content type:
application/json - Secret: set a strong value and store the same value in
scenarix/auto-prsecretAUTO_PR_GH_WEBHOOK_SECRET - Events: select Issue comments and Pull requests
- 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.
- Add GitHub Actions secrets
LINEAR_FEEDBACK_WEBHOOK_SECRETandRESEND_API_KEY(step 4). Use the Resend key from the same accountstudio-backendsends with, so the address inNOTIFY_FROM_EMAILis already a verified sender. - Add
RESEND_READ_API_KEYandMONGODB_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_accesskey. 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. - Push to
mainwith changes underfeedback-worker/**(or rerundeploy-feedback-worker.yml). CI installs deps, runs the Worker tests, syncs secrets, and deploys. - Linear Settings → Webhooks → Create webhook
- URL:
https://auto-pr-feedback-worker.<subdomain>.workers.dev/webhook/linear/feedback - Resource types: Issues only — other resource types deliver payloads with no
data.state, which are rejected but still cost an invocation - Team: the team that owns the intake project
- Copy that webhook's signing secret into
LINEAR_FEEDBACK_WEBHOOK_SECRETand rerundeploy-feedback-worker.yml. - Create the
Skip notifylabel in Linear — the opt-out named byNOTIFY_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. - Move a test issue in the intake project to Done (or Canceled), then check
npx wrangler tailfromfeedback-worker/. Expect onenotifiedline per reporter — the issue's own, plus one for each duplicate of it — each carrying the resolvedoutcome, plus acomment_postedline per email and areport_closedline. The comment is also visible on the ticket itself, which is the quickest check that the key can write. AddSkip notifyto a second issue and close it to confirm the opposite: a singleskip_label_presentline and no email — but still areport_closed, because closing the report is not an email. - Watch the first real intake ticket arrive, again via
wrangler tail. Expect onenotifiedline withoutcome: "acknowledged"— from that point every new report in the intake project is acknowledged, so this is the step that changes what reporters receive. SetACK_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_LABELScannot 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 /emailsanswers with{ id }and nothing else, and Resend sends through SES, which assigns theMessage-IDitself. Supplying one at send time is pointless — it is overwritten.GET /emails/{id}returns it, but refuses a sending key with401 restricted_api_key, and Resend offers onlyfull_accessandsending_access— there is no read-only tier.- So
RESEND_READ_API_KEYis 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.sentwebhook: 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
Doneand back intoIn Progressleaves its reportCLOSED— 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/0in its access list. Scope the database user toreadWriteonrenderboard.bugReportsand 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.stateIdon the update event andcreatefiring 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: falsewith HTTP 200 — logs awarningand 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 (—, ’) 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:
- Immediately acknowledge: "Working on it..."
- Clone the repo, run the AI agent, and open a PR
- 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:
- 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
- CI injects a rendered stacktrace into the prompt and runs the Sentry prompt variant
- 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:
- Existing PR branch is checked out
- Follow-up commits are pushed to that same branch
- 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:
- Worker validates signature, allowlist, and branch policy rules.
- Worker dispatches
auto_pr_review_cctoscenarix/auto-pr. - Worker posts an acknowledgement comment that review has been triggered.
ai-review-worker-cc.ymlruns Claude Code review and posts inline comments/verdict.
Trigger requirements (important)¶
- Only PR comments containing
#auto-prare considered - Comment author must be in the configured allowlist
- Repository must be in
ALLOWED_REPOSfor command workflows - Repository must be in
ALLOWED_REPOS_REVIEWfor 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 Gatewaywith no Worker logs at all. - ngrok outlives
wrangler dev. If the Worker stops while the tunnel stays up, Linear gets502and 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.