---
title: Triage tickets
description: New Linear issues get classified by severity in seconds. Critical ones page on-call — after a human approves in Slack. Routine ones get filed with the reasoning attached.
slug: triage-inbound-bug-reports
group: engineering
trigger: linear issue.created
steps:
  - "agent: classify severity"
  - "slack: alert #on-call"
  - "wait: page now, or hold until morning"
  - "linear: set priority"
integrations:
  - linear
  - slack
outcome: Bug reports are classified and routed within seconds of filing, and nobody gets paged without a human signing off.
publishedAt: "2026-07-14"
updatedAt: "2026-07-14"
durable: true
---

Every triage rotation has the same two failure modes: critical reports that sit
unread overnight, and 3 a.m. pages for issues that could have waited. This job
handles both. Each new Linear issue is classified the moment it's filed;
critical ones alert the on-call channel and _ask_ before paging, routine ones
get filed with the classification reasoning as a comment.

## How it works

1. **Trigger** — a new issue lands in the support team's Linear queue; test
   issues are filtered out by title.
2. **Classify** — an agent labels the issue critical or routine with a
   structured output schema, so the handler branches on a typed enum, not on
   prose.
3. **Routine branch** — the classification reasoning is left as a comment on
   the issue, and the job ends.
4. **Critical branch** — the on-call channel gets an alert, then the job
   _waits_ for a human to choose "page now" or "hold until morning".
5. **Act** — holding sleeps the job for eight hours; either way, the issue
   priority is raised when the wait ends.

The job is **durable**: it can pause on `waitForInput` for as long as the
approval takes and survive restarts while it waits — exactly what a
human-in-the-loop step needs.

## Requirements

- A Terse project (`terse init`) with the **Linear** and **Slack**
  integrations connected (`terse integrate connect linear`, then `slack`).
- `terse generate` run after connecting, so `LinearTeam.*` and
  `SlackChannel.*` exist as typed constants in `src/terse.generated.ts`.
- A Linear team whose new issues should be triaged, and an on-call channel
  for escalations.

## The job

```typescript
import { createJob, generateText, slack, sleep, waitForInput } from "terse-sdk";
import type { LinearIssueCreatedTrigger } from "terse-sdk";
import { z } from "zod";
import {
  Triggers,
  LinearTeam,
  SlackChannel,
  toolbox,
} from "../terse.generated";

createJob({
  name: "Triage inbound bug reports",
  triggers: [Triggers.linear.onIssueCreated({ team: LinearTeam.Support })],
  durable: true,
  filter: async (event) => !event.issue.title.startsWith("[test]"),
  onTrigger: async (event) => {
    const classification = await generateText({
      prompt:
        `Classify this bug report as critical (data loss, security, outage) or routine. ` +
        `Explain your reasoning in one sentence. Context: ${event.formatForAgentRunner()}`,
      skills: [],
      outputSchema: Classification,
    });

    switch (classification.severity) {
      case "critical":
        return escalateCritical(event, classification);
      case "routine":
        return fileRoutine(event, classification);
      default:
        throw classification.severity satisfies never;
    }
  },
});

async function escalateCritical(
  event: LinearIssueCreatedTrigger,
  classification: Classification,
): Promise<void> {
  await toolbox.slack.sendMessage({
    channelId: SlackChannel.OnCall.channelId,
    message: `Critical bug: ${event.issue.title} — ${classification.reason}`,
    thread_ts: "",
    blocks: "",
  });
  const approval = await waitForInput({
    via: slack({ channel: SlackChannel.OnCall.channelId }),
    prompt: "Page the on-call engineer?",
    details: { issue: event.issue.title, reason: classification.reason },
    options: [
      { id: "page", label: "Page now" },
      { id: "hold", label: "Hold until morning" },
    ],
  });
  if (approval.choice === "hold") {
    await sleep("8h");
  }
  await toolbox.linear.updateIssue({ issueId: event.issue.id, priority: 1 });
}

async function fileRoutine(
  event: LinearIssueCreatedTrigger,
  classification: Classification,
): Promise<void> {
  await toolbox.linear.createComment({
    issueId: event.issue.id,
    body: `Auto-triaged as routine: ${classification.reason}`,
  });
}

const Classification = z.object({
  severity: z.enum(["critical", "routine"]),
  reason: z.string(),
});
type Classification = z.infer<typeof Classification>;
```

The `outputSchema` is the trust boundary: the agent's answer is validated
against the Zod schema before the handler sees it, so the `switch` can be
exhaustive and the `satisfies never` default makes adding a third severity a
compile-time to-do, not a runtime surprise.

## Make it yours

- **Tune the taxonomy.** Swap critical/routine for your own severity levels —
  extend the enum and the switch grows with it.
- **Route by team.** Classify which team owns the bug too, and comment with an
  owner suggestion or set the Linear team directly.
- **Escalate on silence.** After `waitForInput`, race a timeout: if nobody
  responds in 30 minutes, page anyway.
- **Feed it from support.** Add a Slack trigger on your support channel so
  user-reported issues enter the same funnel.

## For coding agents

This page is a build template. To implement it in a Terse project:

1. Satisfy the Requirements section above (init, connect, generate).
2. Create `src/jobs/triage-inbound-bug-reports.ts` with the job above and add
   `import "./jobs/triage-inbound-bug-reports"` to `src/terse.jobs.ts`.
3. This job must keep `durable: true` — it uses `waitForInput` and `sleep`,
   which only exist in durable mode.
4. The constants (`LinearTeam.*`, `SlackChannel.*`) and factories must come
   from the user's own `src/terse.generated.ts` — verify every name there and
   substitute the user's real team and channel. Never invent names.
5. Build it in milestones: trigger + stub first, then classification, then the
   routine branch, then the critical branch. Prove each green with
   `terse test run "Triage inbound bug reports"` before the next, and deploy
   with `terse deploy` only after the user confirms.
