Blueprint

Inbox-to-issue agent: turn support email into draft issues you approve

A scheduled agent reads your support inbox or Slack channel, merges duplicate reports, writes repro steps and a severity guess, and queues draft GitHub or Linear issues. Nothing is filed until a human types y.

Indie hackers and small dev teams who answer their own support·1 to 2 hours, our estimate (not measured yet)·Two model calls per bug cluster per run, plus one weekly audit call. We haven't measured token cost on a real inbox yet.· Tested·Oct 19, 2026

We ran fingerprint.py and the dry run of approve.py on an 8-message sample inbox we wrote: the script merged the exact repeats (8 messages into 6 groups) and printed the gh commands without creating anything. We have not run the three prompts or the inbox pull on a real inbox yet, so those parts are marked untested.

Written by Mara at Sig

What you need

  • Read-only access to one support source: a Gmail label or one Slack channel
  • Python 3 (the two scripts use only the standard library)
  • A model you can call from a script or a coding agent CLI
  • The GitHub CLI (gh) logged in to your repo, or a Linear API key

Steps

  1. 1

    Connect one source, read-only

    Untested

    Pick one place reports land. Gmail: filter support mail into a "support" label and pull it with the Gmail API (users.messages.list, q="label:support newer_than:1d") on the gmail.readonly scope. Slack: a bot with only channels:history, invited to #support, calling conversations.history since the last run.

    Save each message as {"id", "from", "subject", "body", "received"} in messages.json. Read-only means the agent can't reply to customers or close anything.

  2. 2

    Write your label set

    Create labels.md with your types, severity rules and area labels, plus agent-draft, which every draft gets. Both prompts read it, so the agent sorts reports your way. Create the same labels in GitHub or Linear.

  3. 3

    Merge exact repeats with a script

    Run python3 fingerprint.py messages.json > fingerprinted.json. It strips ids, numbers, emails and URLs from the first error line (or the subject) and gives identical reports one group number. Cheap and certain, so the model only judges the hard cases.

  4. 4

    Cluster the rest with the dedupe prompt

    Untested

    Send fingerprinted.json with prompt 1. You get clusters with the shared symptom, a type, exact quotes and an "unsure" note. Bugs never merge with billing or feature requests. Read the unsure notes first; wrong merges show up there.

  5. 5

    Draft each bug cluster with the repro prompt

    Untested

    For each bug cluster, send prompt 2 with labels.md and the cluster's messages. You get a title, a markdown body (summary, repro steps from the reports, expected, actual, report ids, what's missing), labels, a severity guess and a confidence. Collect them in drafts.json.

  6. 6

    Approve by hand

    Run python3 approve.py drafts.json --repo owner/name. It shows each draft and runs gh issue create only when you type y (e edits the title first). Use --dry-run the first week. Every decision goes to audit.jsonl.

    On Linear, file approved drafts into your team's Triage with the issueCreate API instead (untested by us).

  7. 7

    Schedule it

    Run steps 1 to 5 once or twice a day: cron, a CI schedule, or a headless agent run (claude -p or codex exec). Keep step 6 manual. The agent fills the queue; a person empties it.

  8. 8

    Audit once a week

    Untested

    Every Friday, send audit.jsonl and the issues you closed as duplicate or wrong with prompt 3. It lists skipped drafts, bad merges and at most 3 prompt changes. Apply one change at a time.

Prompts

Prompt 1: dedupe

Untested

Step 4. Paste fingerprinted.json at the end.

You are triaging support messages for <PRODUCT>. Below is a JSON list of messages. Each has an id, a subject, a body, and a "group" number from a script that already merged exact repeats.

Merge messages that describe the same underlying problem, even when the wording differs. Only merge when you can name the shared symptom. If you are not sure, keep them apart and say why.

Rules:
- Never invent details that are not in the messages.
- Never merge billing, account or feature requests with bugs.
- Quote the exact error text when there is one.

Return only JSON, no prose:
[{"cluster": 1, "ids": ["m1", "m2"], "symptom": "one sentence", "type": "bug|billing|account|feature|question", "evidence": ["exact quote"], "unsure": "empty, or why these might not belong together"}]

Messages:
<PASTE fingerprinted.json>

Prompt 2: draft issue with repro steps and severity

Untested

Step 5, once per bug cluster.

Write a draft bug report for one cluster of support messages. You have not run the product. You only have what users wrote.

Our labels:
<PASTE labels.md>

Return only JSON, no prose:
{"title": "under 80 characters, names the symptom and the area",
 "body": "markdown with these sections: Summary, Repro steps (from reports, not reproduced), Expected, Actual, Reports, Missing",
 "labels": ["bug", "agent-draft", "<one area label>"],
 "severity": "S1|S2|S3",
 "confidence": "high|medium|low",
 "reports": ["message ids"]}

Severity: S1 = people can't log in, pay, or reach their data. S2 = a main feature fails with no workaround. S3 = everything else.
Repro steps may only use actions a user described. If the reports don't give enough to reproduce it, write "Not enough detail to reproduce" and put the questions to ask under Missing.
Never include email addresses, names, invoice numbers, or anything else that identifies a customer.

Cluster:
<PASTE one cluster from prompt 1, plus the full text of its messages>

Prompt 3: weekly audit

Untested

Step 8, Fridays.

Below is audit.jsonl from the last 7 days (every draft and whether a human created or skipped it), followed by the issues we later closed as duplicate, wrong, or won't fix.

List:
1. Drafts we skipped, and the most likely reason for each.
2. Clusters that should have been merged, or split.
3. At most 3 changes to prompt 1 or prompt 2 that would have prevented the most mistakes. Quote the draft titles that each change fixes.

Don't suggest new tools or new steps.

audit.jsonl:
<PASTE>

Closed issues:
<PASTE titles and close reasons>

Script: fingerprint.py

Step 3. Python 3, standard library only. Ran on our 8-message sample.

#!/usr/bin/env python3
"""Group support messages that are obviously the same report before any model sees them.

Input: a JSON list of {"id", "from", "subject", "body", "received"} (one per email or Slack message).
Output: the same list with "fp" (fingerprint) and "group" added, sorted by group, to stdout.

The fingerprint is the first error-looking line (exception names, HTTP codes, "error:" lines) with
numbers, ids, emails and URLs stripped. Messages with no error line fall back to the normalized subject.
This only catches exact repeats. The dedupe prompt handles reports that describe the same bug differently.

Usage: python3 fingerprint.py messages.json > fingerprinted.json
"""
import hashlib, json, re, sys

ERROR_LINE = re.compile(r"(error|exception|traceback|failed|failure|\b[45]\d\d\b|timeout|crash)", re.I)
# Start the fingerprint at the error itself, so "Console says: TypeError ..." and "TypeError ..." match.
ERROR_START = re.compile(r"(\w*(Error|Exception)\b|\b(GET|POST|PUT|PATCH|DELETE)\b|\b[45]\d\d\b)")

def normalize(text: str) -> str:
    t = text.lower()
    t = re.sub(r"https?://\S+", "<url>", t)
    t = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "<email>", t)
    t = re.sub(r"\b[0-9a-f]{8,}\b", "<id>", t)
    t = re.sub(r"\d+", "<n>", t)
    t = re.sub(r"^(re|fwd?|fw):\s*", "", t)
    return re.sub(r"\s+", " ", t).strip()

def fingerprint(msg: dict) -> str:
    for line in (msg.get("body") or "").splitlines():
        if ERROR_LINE.search(line) and len(line.strip()) > 8:
            start = ERROR_START.search(line)
            core = line[start.start():] if start else line
            return "err:" + hashlib.sha1(normalize(core).encode()).hexdigest()[:10]
    return "subj:" + hashlib.sha1(normalize(msg.get("subject") or "").encode()).hexdigest()[:10]

def main():
    if len(sys.argv) != 2:
        sys.exit(__doc__)
    msgs = json.load(open(sys.argv[1]))
    groups: dict[str, int] = {}
    for m in msgs:
        m["fp"] = fingerprint(m)
        m["group"] = groups.setdefault(m["fp"], len(groups) + 1)
    msgs.sort(key=lambda m: (m["group"], m.get("received", "")))
    json.dump(msgs, sys.stdout, indent=2)
    print(f"\n# {len(msgs)} messages, {len(groups)} groups", file=sys.stderr)

if __name__ == "__main__":
    main()

Script: approve.py

Step 6. Needs gh logged in. The dry run ran on our sample.

#!/usr/bin/env python3
"""Show each draft issue and create it only when a human types y. Nothing is created without a y.

Input: drafts.json, a list of {"title", "body", "labels": [...], "severity", "confidence", "reports": [ids]}
(the output of the draft-issue prompt).

Usage:
  python3 approve.py drafts.json --repo owner/name            # GitHub, via the gh CLI (gh auth login first)
  python3 approve.py drafts.json --repo owner/name --dry-run  # print the gh commands, create nothing
Approved, skipped and edited drafts are appended to audit.jsonl for the weekly review.
"""
import json, shlex, subprocess, sys, tempfile, datetime

def main():
    args = sys.argv[1:]
    if not args or "--repo" not in args:
        sys.exit(__doc__)
    path = args[0]
    repo = args[args.index("--repo") + 1]
    dry = "--dry-run" in args
    drafts = json.load(open(path))
    audit = open("audit.jsonl", "a")
    for i, d in enumerate(drafts, 1):
        print(f"\n--- Draft {i}/{len(drafts)}  severity={d.get('severity')}  confidence={d.get('confidence')}  reports={len(d.get('reports', []))}")
        print(d["title"])
        print(d["body"])
        answer = "y" if dry else input("Create this issue? [y]es / [n]o / [e]dit title: ").strip().lower()
        if answer == "e":
            d["title"] = input("New title: ").strip() or d["title"]
            answer = "y"
        decision = "created" if answer == "y" else "skipped"
        if answer == "y":
            with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
                f.write(d["body"])
            cmd = ["gh", "issue", "create", "--repo", repo, "--title", d["title"], "--body-file", f.name]
            for label in d.get("labels", []):
                cmd += ["--label", label]
            if dry:
                print("DRY RUN:", " ".join(shlex.quote(c) for c in cmd))
                decision = "dry-run"
            else:
                subprocess.run(cmd, check=True)
        audit.write(json.dumps({"at": datetime.datetime.now().isoformat(timespec="seconds"), "title": d["title"], "decision": decision, "severity": d.get("severity"), "reports": d.get("reports", [])}) + "\n")
    audit.close()

if __name__ == "__main__":
    main()

What breaks

  • Two different bugs share a generic error (a 500, a timeout) and get merged. Check the unsure notes and split by hand.
  • Reports with no error text ("the button spins") don't fingerprint together, so they depend on the model.
  • Repro steps can sound more certain than the reports. Keep the "from reports, not reproduced" heading.
  • Customer details can leak into an issue body. Check before you type y on a public repo.

Skip this if

You get fewer than about 10 support messages a week. Reading them yourself is faster than setting this up.