appscript.dev
Automation Intermediate Sheets

Build an AI bug-triage system

Categorise and prioritise Northwind's reported issues automatically — type, severity, owner.

Published Jan 22, 2026

Bug reports land in Northwind’s tracker faster than anyone can sort them. Each one needs the same three judgements before work can start — is it actually a bug, how urgent is it, and whose desk does it belong on — and until someone makes those calls the report just sits there. The triage is not hard, it is just relentless, and that is exactly the kind of work to hand off.

This script reads each untriaged row from a bug Sheet and asks Claude to make those three calls in one pass: a type, a severity, and a suggested owner. It writes them straight back into the Sheet, so the tracker stays sorted without anyone having to babysit the queue.

What you’ll need

  • A Google Sheet of bug reports with a header row, a description column, and empty type, severity, and suggestedOwner columns for the output.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.

The script

// The Sheet that holds incoming bug reports.
const BUGS_SHEET_ID = '1abcBugsId';

/**
 * Triages every bug row that has a description but no priority yet,
 * filling in type, severity, and suggested owner.
 */
function triageBugs() {
  // 1. Read the bug Sheet and map header names to column indexes.
  const sheet = SpreadsheetApp.openById(BUGS_SHEET_ID).getSheets()[0];
  const [h, ...rows] = sheet.getDataRange().getValues();
  if (!rows.length) {
    Logger.log('No bug reports — nothing to triage.');
    return;
  }
  const col = Object.fromEntries(h.map((k, i) => [k, i]));

  // 2. Walk the rows; triage each one that needs it.
  let triaged = 0;
  rows.forEach((r, i) => {
    // Skip rows already triaged, or with no description to read.
    if (r[col.priority] || !r[col.description]) return;

    // 3. Ask Claude for a strict JSON verdict — fixed keys, fixed
    //    allowed values, so the result is always parseable.
    const prompt =
      'Triage this Northwind bug. Return JSON ' +
      '{"type": "bug|feature|question", ' +
      '"severity": "critical|high|medium|low", ' +
      '"suggestedOwner": "design|dev|ops"}.\n\n' + r[col.description];
    const out = JSON.parse(callClaude(prompt));

    // 4. Write the three verdicts back into their columns.
    sheet.getRange(i + 2, col.type + 1).setValue(out.type);
    sheet.getRange(i + 2, col.severity + 1).setValue(out.severity);
    sheet.getRange(i + 2, col.suggestedOwner + 1).setValue(out.suggestedOwner);
    triaged++;
  });
  Logger.log('Triaged ' + triaged + ' bugs.');
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code.
 */
function callClaude(prompt) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('ANTHROPIC_API_KEY');
  const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
    payload: JSON.stringify({
      model: 'claude-haiku-4-5-20251001',
      max_tokens: 150,
      messages: [{ role: 'user', content: prompt }],
    }),
    muteHttpExceptions: true,
  });
  return JSON.parse(res.getContentText()).content[0].text.trim();
}

How it works

  1. triageBugs opens the bug Sheet, reads every row, and builds a col map from header names to indexes so the code never depends on column order.
  2. If the Sheet has no data rows it logs that and stops before any API calls.
  3. For each row it skips anything already triaged or missing a description — the r[col.priority] guard means a re-run only handles new reports.
  4. The prompt pins Claude to a strict JSON shape: fixed keys, and a closed set of allowed values for each one. That is what makes JSON.parse reliable.
  5. Claude Haiku returns the verdict — Haiku is right for short classification work that does not need deeper reasoning.
  6. The three fields are written back into their own columns at i + 2, which accounts for the header row plus the zero-based loop index.

Example run

Suppose the bug Sheet holds two untriaged rows:

descriptiontypeseveritysuggestedOwner
Checkout button does nothing on mobile Safari, can’t pay.(blank)(blank)(blank)
Could the dashboard remember my last filter?(blank)(blank)(blank)

After a run the three columns are filled:

descriptiontypeseveritysuggestedOwner
Checkout button does nothing on mobile Safari, can’t pay.bugcriticaldev
Could the dashboard remember my last filter?featurelowdesign

A raw queue becomes a sorted one — the blocking payment bug is flagged critical and routed to dev, the nice-to-have lands with design as a low-priority feature.

Run it

This is an on-demand job — run it when reports have piled up:

  1. In the Apps Script editor, select triageBugs and click Run.
  2. Approve the authorisation prompt the first time.
  3. Open the Sheet and review the triage before acting on it.

To triage automatically as reports come in, add a time-driven trigger: open Triggers (the clock icon), Add Trigger, choose triageBugs, set it Time-driven on an hourly timer, and save.

Watch out for

  • JSON.parse will throw on malformed output. Claude usually returns clean JSON for a tight schema, but if it ever wraps the reply in a code fence the parse fails — strip fences or log the raw reply before tightening the prompt.
  • The allowed values are fixed in the prompt. If you add an owner or a severity band, update the prompt’s value list too, or Claude will map to the old set.
  • A re-run skips triaged rows via the priority guard. To re-triage a bug, clear its row first — note the guard checks priority, so that column must exist even though the script writes type, severity, and suggestedOwner.
  • Triage is a suggestion. Treat the suggested owner and severity as a starting point a person can override — Claude reads the description, not the roadmap.

Related