---
title: Bug Report Intake (part 3)
---

# Bug Report Intake (part 3)

## What it is

This is part 3 of the [Bug Report Intake](bug-report-intake.md) page. It covers the other half of intake — what actually lands in an auto-spawned investigation (the report fence, your own appended instructions, email attachments and in-app screenshots), how a follow-up reply threads back to the session it belongs to, how repeat reports of the same issue collapse into one investigation, and the safety guarantees that hold across every channel.

## Where to find it

There is nothing new to open — the settings this part describes live in places you already have. The global appended-instructions box sits in the **Bug Intake** panel above its tabs, the per-project one is in that project's **Edit Project** dialog, the duplicate-detection switch is in the Bug Intake panel header beside **Pause all polling**, and screenshots are attached from the in-app **Send feedback** dialog.

## How it behaves

### What goes into a spawned session (email, Sentry, and GitHub)

When intake spawns a session, two things land in it:

1. **An operator chat row** — what the operator sees in the conversation log. For an email-routed session it's `From: / Subject: / Attachments:` plus the fenced body. For a Sentry-routed session it's the flattened event payload (`Title`, `Level`, `Culprit`, `First seen`, `Last seen`, `Count`, `Permalink`).
2. **An initial prompt to the agent** — the same envelope + fenced body, prefixed by a short trusted-instruction preamble: the fenced content is untrusted external input, then a shared "What to do" block (`BUG_INTAKE_INVESTIGATION_STEPS`, the one copy all four surfaces splice) telling the agent to take the report at face value — for a bug report, the reported error is real and the job is to find and fix its true cause; for a feature request, work out what the reporter actually needs; for a question or feedback item, find the accurate answer, or the change it points to; then — **before starting any work** — **analyze the item and report back a plain-English, markdown-formatted explanation of what's being reported or requested plus its recommended next steps, and wait for your go-ahead**; and only then **drive the work with the `/dev-pipeline` skill** when it's installed (otherwise investigate the same way — the skill is opt-in / default-off), reporting in **plain English with short summaries** and **waiting for approval before changing code**.

Both rows go through `stripIntakeMarkers()` first — if the tester's body itself contains a line consisting only of `BEGIN-USER-REPORT` or `END-USER-REPORT`, that line is removed so an attacker can't inject a false closing fence. Inline mentions of those tokens inside prose remain visible (harmless data).

Session rows are stamped `source = 'bug_intake'` (email), `source = 'sentry_intake'` (Sentry), or `source = 'github_intake'` (GitHub) so they're distinguishable from manually-started sessions, and `silent = 0` so they're fully visible in the sidebar and inbox.

### Appended investigation instructions (global + per-project)

You can append your own free-form instructions to the investigation prompt every bug report receives — without touching code. Two independent text boxes stack:

- **Global**: one box in the **Bug Intake** sidebar view (above the Sources / Review / Audit tabs). Its text is appended to **every** auto-triaged report across **all** projects and **all three sources** (email, Sentry, in-app feedback).
- **Per-project** — one box per project, in **Edit Project → Appended investigation instructions**. Its text is appended only to reports routed to **that** project.

When a report for project X is triaged, the agent prompt is assembled as: the fixed investigation framing → the numbered "What to do" steps → **your global text, then project X's text** (labeled `Additional operator instructions:`) → the untrusted `BEGIN-USER-REPORT` / `END-USER-REPORT` fence holding the report. Both layers are optional; leave them blank and the prompt is byte-for-byte identical to before the feature existed.

**Why it's placed where it is.** Your appended text is _operator-authored_ (you typed it into Omniscio's own UI), so it is trusted — it sits **above** the report fence, in the same region as the built-in instructions. The reporter's words stay **inside** the untrusted fence exactly as before, so the prompt-injection protection is unchanged. As defense-in-depth, a stray `BEGIN-USER-REPORT` / `END-USER-REPORT` marker line you happen to type is stripped from your text so it can't forge a second fence.

Each box is capped at 10,000 characters (`BUG_INTAKE_APPEND_PROMPT_MAX`). The global value lives in the `bugIntakeAppendPrompt` AppSettings field; the per-project value in the `projects.bug_intake_append_prompt` column (sibling of `bug_intake_slug`, added by a dated ledger migration under `src/main/db/migrations/`). The per-project box is **independent of the email-slug toggle** — it applies to a project's Sentry and in-app reports even when email intake is off for that project.

### Attachments (email only)

If the tester's email carries attachments, Omniscio processes them through the same 4-route delivery pipeline used by the catchall inbound-email sessions (`processEmailAttachments`). Each attachment is routed by content type:

| Route          | Types                                                                        | Delivery into the session                                                                                                                                   |
| -------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Image**      | `image/png`, `image/jpeg`, `image/gif`, `image/webp`                         | Anthropic Vision `image` content block on the first stdin message (base64); the agent can "see" the screenshot directly                                     |
| **PDF**        | `application/pdf`                                                            | Native Vision `document` content block AND saved to `<workdir>/.claude/amc-attachments/<sessionId>/<filename>` so the agent can also `Read` / edit the file |
| **Office doc** | `.docx`, `.xlsx`, `.pptx`                                                    | Text extracted via `office-text-extract.ts`, saved to workdir as `<basename>.md`, and the path is injected into the prompt                                  |
| **Text doc**   | `.md`, `.txt`, `.csv`, `.json`, `.html`, `.xml`, `.tsv`, `.log`, `.markdown` | Saved verbatim to the session workdir; path injected into the prompt via `buildPromptWithAttachments`                                                       |

Skipped attachments (oversize, unsupported format, office-extract failure, encrypted PDF) are NOT dropped silently — they render as a `Note: N attachment(s) could not be included` block **outside** the BEGIN/END fence in both the operator chat row and the agent prompt, so the diagnostic is visible but structurally separated from sender-controlled fenced content. Per-file caps mirror the in-app attachment rules: 30 MB per image, 32 MB per document. No count cap.

Sentry intake has no attachment pipeline — the Sentry event is the entire payload.

### In-app feedback screenshots (reach the investigator)

The in-app feedback dialog (the **Send feedback** button) lets a reporter attach up to 5 screenshots. Those already travel to the Resend email and the report database (Firestore); they now also reach the **auto-spawned investigator** so the agent can _see_ the bug, not just read about it. (Before, an in-app report spawned an agent that was structurally blind to the attached screenshot — e.g. a "Screenshot for ticket …" report with nothing for the agent to look at.)

The in-app path is dual-written (Resend email + a Firestore mirror doc); the Firestore leg is what spawns the investigation. Because a Firestore document is capped at 1 MB, screenshots ride **inline** (base64) through a budget gate:

- Only the four Anthropic-Vision image formats (`png`, `jpeg`, `gif`, `webp`) are forwarded — each becomes an `image` content block on the agent's first message, exactly like the email **Image** route above.
- The total embedded base64 is capped (~700 KB, headroom under the 1 MB doc limit). Screenshots that don't fit, or aren't a Vision format, are **dropped from this leg only** and the agent prompt notes how many — the email copy still carries the full-resolution originals.

Unlike the email pipeline, the in-app path forwards **images only** (no PDF / Office / text-doc routes) — it's screenshots-for-investigation, not a general attachment channel. The screenshots ride inline rather than through any shared/public storage bucket, since screen captures can be sensitive.

#### The operator pickup is monitored — a durable alarm when it stalls (2026-07-06)

An in-app report only becomes a session because the **operator install** (the one flagged `amcBugReportOperator: true`, normally the operator's) polls the `amc_bug_reports` relay every 15s and spawns per new doc. If that poll can't reach the relay (a signed-out operator, a cloud/relay outage), the tick was previously only a **rate-limited WARN** — invisible — so a sustained outage would silently strand every reporter's doc in the cloud, uncollected.

A durable, self-clearing **inbox card** now closes that gap. Once the poll has been failing continuously past **5 minutes** (measured from the last success, or this run's poll-start if it has never succeeded this run), one card is raised — _"Bug Intake: in-app reports aren't being picked up"_ — on desktop, mobile, and the badge (a DB row, so it survives a restart). It is raised **once** (guarded by `findActiveByDedupKey` on the fixed key `bug-report-operator-poll-broken`, never re-raised each tick) and **auto-clears** on the next successful poll or when the operator flag is turned off. Gated by the `AMC_DISABLE_OPERATOR_POLL_ALERT` env kill switch, and bulletproof (any error → no-op, never breaks a poll tick). It is the single-poll sibling of the per-source Sentry/GitHub "source broken" card documented above. Producer: `src/main/services/bug/bug-report-operator-alert.ts`; invariant `operator-poll-broken-alert` in `operator-console-relay-contract.md`.

### Replies thread back to the same email-routed session

When a routed email spawns a session, Omniscio inserts an `email_inbound_tracking` row mapping the AgentMail `agentmail_message_id` → spawned `session_id` (plus `thread_id` when threaded). The insert lives inside the same SQL transaction as the session-row insert, so a concurrent inbound poll racing ahead trips the `PRIMARY KEY` on `agentmail_message_id` and rolls the spawn back rather than creating an orphan.

A subsequent reply on the same thread then:

- Has subject `Re: [BUG: amc] ...`, which fails the strict `^\[` anchor in the bug-intake parser — so it is **not** spawn-routed again
- Falls through to the normal email-inbound flow, which finds the thread in `email_inbound_tracking` and appends the reply as a regular operator message in the existing session

Sentry has no analog — each Sentry issue maps to one intake row by `external_id`, and the dedupe layer guarantees one-and-only-one session per issue.

### Duplicate detection — one investigation per underlying issue (24h)

A bug can land more than once: a tester emails the same complaint twice, or Sentry fires the same crash from two devices within minutes. Left alone, each arrival spawns its own investigator — two agents chewing the same issue, burning tokens and cluttering the inbox. Intake dedup collapses **the same underlying issue, reported again within 24 hours, into a single investigation.** Three lines of defense run in order, each catching what the one before it cannot:

#### 1. Exact-id dedup (always on, never gated)

If the _same_ external id arrives twice — same AgentMail `agentmail_message_id` (email), same Sentry `issue.id` (Sentry) — it is collapsed at the `bug_intake_processed` PRIMARY KEY before anything else runs (the `INSERT OR IGNORE` / `tryClaimIntakeDedupe()` described under [Safety guarantees](#safety-guarantees) below). This is the front line and is **never** affected by the toggle. The two layers below exist only to catch the harder _different-id, same-content_ case that exact-id matching can't see.

#### 2. Layer 1 — the hint (intake-side, never archives)

Before spawning, intake computes a cheap structural **signature** of the incoming report — normalized **sender + subject** for email, **culprit + title** for Sentry (never the body, so nothing sensitive is stored or indexed). If a _routed_ intake from the **same source and same project** carried the same signature within the last **24 hours** and its session is still live, the new spawn's prompt is prepended with a one-line hint: _"this looks like session X — compare the two reports before investigating."_

Layer 1 **only hints.** It never archives, never blocks the spawn, never decides anything on its own. A false-positive signature collision costs exactly one extra sentence in a prompt — so the match is deliberately cheap and the cost of being wrong is near zero.

#### 3. Layer 2 — the decision (agent-side, archives)

The spawned investigator reads the _actual bodies_ of both reports (its own and the hinted one). Only if it confirms a true duplicate does it act — and only then does anything get archived:

- The **current** (duplicate) session is archived automatically.
- The **original** session gets a _"Same issue reported again …"_ note in its conversation log — with a timestamp, the source, and a link to the duplicate — so the breadcrumb is visible to you.

Net effect: you see **one** live investigation, plus a note on the original telling you it came in again. Guards protect this archive-on-behalf-of path: the calling session must itself be a fresh (under 1h) intake-spawned session, it can't archive itself, the original must still exist, and two agents that hint at each other can't archive each other in a tie. The note is always posted **before** the archive, so if the archive ever fails the duplicate stays alive with the trail intact rather than vanishing silently.

#### The "Dedup pre-check" toggle

The Bug Intake view header carries a **Dedup pre-check** toggle (next to **Pause all polling**), bound to the `intakeDedupEnabled` setting (**on by default**). When off, no signature is computed and no hint is ever added to a spawn — Layers 1 and 2 go inert. Exact-id dedup (line 1 above) keeps running regardless; the toggle governs only the two similarity layers.

### Safety guarantees

Both intake paths share these:

- **Manual spawn is one click.** A human-initiated spawn of a paid investigation — the Review tab's **Spawn now** and the inbox detail pane's **Spawn** (the new-session icon button — a click or the `N` key) — starts the billable session immediately, with no confirmation dialog. A duplicate still can't burn extra tokens: the inbox pane's in-flight guard collapses a fast double-press to one spawn, and the backend is idempotent by external id (`bug_intake_processed`, INSERT OR IGNORE), so a repeat can never start a SECOND billable session. (Auto-spawn from a polled match is governed by the triage gate + caps.)
- **Untrusted-data framing.** The agent prompt wraps the external payload in `BEGIN-USER-REPORT` / `END-USER-REPORT` markers and explicitly tells the agent the content is untrusted external input
- **Fence-marker stripping.** `stripIntakeMarkers()` removes whole-line occurrences of those marker tokens from the source body before fencing
- **Idempotency by external ID.** Each AgentMail `agentmail_message_id` (email) or Sentry `issue.id` (Sentry) is stamped into `bug_intake_processed` exactly once via `INSERT OR IGNORE`; restarts, parallel polls, and replays cannot spawn a session twice
- **Orphan cleanup on launch failure.** If the session row is created but the CLI process fails to launch, the session is soft-deleted (`is_deleted = 1`) so no ghost row remains in the sidebar
- **90-day retention sweep.** `intake-retention-sweeper.ts` (registered with the pausable service registry, fires 10 seconds after launch and every 6 hours after) purges `bug_intake_processed` rows older than 90 days. Long enough to investigate a regression that surfaced weeks after the spawning event, short enough that the Audit tab stays scannable. Audit history is preserved across source deletion (no CASCADE from `project_intake_sources` → `bug_intake_processed`), so deleting a source intentionally keeps its decision history visible until retention expires it.

Email-only:

- **Prescreen first.** The email router runs ONLY after the inbound prescreen returns `safe: true` — prompt-injection emails are blocked upstream
- **Strict subject regex.** `[a-z0-9-]{1,64}` rejects unicode, traversal characters, and oversize values
- **30-second cooldown per slug.** Back-to-back emails referencing the same slug get the second tagged `cooldown` rather than spawning a parallel session
- **Daily cap per project.** Default 50 routed emails per project per day; configurable via `bugIntakeDailyCap`

Sentry-only:

- **First-connect cap of 5.** A brand-new source's first poll spawns at most 5 sessions; the rest are deferred to the Review tab (recorded as `unrouted` / `pending review`) so a misconfigured org doesn't carpet-bomb your inbox
- **Per-source mutex.** The scheduler holds an in-memory mutex per source so overlapping ticks short-circuit cleanly (manual "Poll now" while a scheduled tick is mid-flight)
- **24h escalation push.** Continuous-failure beyond a day fires a single `intake:source-error-escalation` push the view surfaces as a red badge
- **Durable broken-source inbox card.** Past ~1h of continuous failure a persistent, self-clearing inbox card is raised per source (desktop + mobile + badge, survives restart, auto-clears on recovery / disable / delete) — see "Durable 'source broken' inbox card" above
- **IPC + service-layer Zod re-parse.** Every handler that touches `config_json` (`CREATE`, `UPDATE`, `TEST_CONNECTION`) re-validates the parsed JSON through `sentryConfigSchema` before the DB write or outbound HTTP. The renderer form mirrors the same `[a-z0-9_-]{1,64}` regex client-side, but client validation can be bypassed via a direct IPC call — never trust the renderer. Enforced by `tests/unit/lint/intake-source-validation.test.ts`

## Related

[Part 1](bug-report-intake.md) is the overview and the email channel; [part 2](bug-report-intake-part-2.md) covers Gmail, Sentry and GitHub source setup; [part 4](bug-report-intake-part-4.md) covers the routing decision table, analytics, out-of-scope notes and the code map. For the alert card raised when in-app reports stop being collected, see [inbox alerts](inbox-alerts.md), and for the mail channel the reply-back path rides on, see [Agent email](agent-email.md).
