Triage tickets
Bug reports are classified and routed within seconds of filing, and nobody gets paged without a human signing off.
- onlinear issue.created
- agentclassify severity
- slackalert #on-call
- waitpage now, or hold until morning
- linearset priority
durable — pauses for approvals, survives restarts
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
- Trigger — a new issue lands in the support team's Linear queue; test issues are filtered out by title.
- 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.
- Routine branch — the classification reasoning is left as a comment on the issue, and the job ends.
- Critical branch — the on-call channel gets an alert, then the job waits for a human to choose "page now" or "hold until morning".
- 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, thenslack). terse generaterun after connecting, soLinearTeam.*andSlackChannel.*exist as typed constants insrc/terse.generated.ts.- A Linear team whose new issues should be triaged, and an on-call channel for escalations.
The job
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:
- Satisfy the Requirements section above (init, connect, generate).
- Create
src/jobs/triage-inbound-bug-reports.tswith the job above and addimport "./jobs/triage-inbound-bug-reports"tosrc/terse.jobs.ts. - This job must keep
durable: true— it useswaitForInputandsleep, which only exist in durable mode. - The constants (
LinearTeam.*,SlackChannel.*) and factories must come from the user's ownsrc/terse.generated.ts— verify every name there and substitute the user's real team and channel. Never invent names. - 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 withterse deployonly after the user confirms.

Apollo
PostHog