Triage tickets

Bug reports are classified and routed within seconds of filing, and nobody gets paged without a human signing off.

  1. onlinear issue.created
  2. agentclassify severity
  3. slackalert #on-call
  4. waitpage now, or hold until morning
  5. linearset priority

durable — pauses for approvals, survives restarts

Build it with your coding agent

Paste into any coding agent with the Terse skills installed. No skills yet? Install them in one command.

/terse-create build https://www.useterse.ai/use-cases/triage-inbound-bug-reports.md

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.