Flag customer escalations that have gone quiet in Linear

See open Linear escalations with no recent update in a weekday Slack digest, so the team can check their status before the customer has to chase it.

Automate.ax team6 min read

Integrations used in this example

See quiet escalations in the team's daily workflow

Customer escalations often need engineering work and account communication at the same time. When an issue sits without an update, no one may realize the customer is waiting.

This example checks every page of issues in one Linear team with your escalation label. Open issues with no update for your chosen number of days appear in a weekday Slack digest with their assignees and links. The digest reports the full count and shows the first 30. The team decides whether to get a status or contact the customer.

The poster described Jira escalations going quiet for days or weeks until a customer threatened cancellation or an executive became involved. This example applies the same status check to Linear.Source: a SaaS team's quiet customer escalations

What you'll tell the agent

  • A Linear team, the label reserved for customer escalations, and the number of quiet days that warrants attention.
  • A Slack channel where the responsible team checks daily status.
  • Account authorization for Linear and Slack. Slack requires a paid workspace for the current Automate.ax connection.

The agent will create the project and ask you for these details. When Automate.ax asks you to connect an account, authorize the integrations you want this workflow to use.

The automation

The custom Linear action follows every result page and checks each issue's last update. The Slack digest links to the first 30 quiet issues and reports the total count.

linear-stalled-escalations.automation.ts
import { automation, defineAction, onSchedule, t } from "automate.ax"
import { getLinearApi } from "automate.ax/linear"
import { slack } from "automate.ax/slack"
import { z } from "zod"

const pageSchema = z.object({
  issues: z.object({
    nodes: z.array(
      z.object({
        identifier: z.string(),
        title: z.string(),
        url: z.url(),
        updatedAt: z.iso.datetime(),
        assignee: z.object({ name: z.string() }).nullable(),
        state: z.object({ type: z.string() }),
      }),
    ),
    pageInfo: z.object({
      hasNextPage: z.boolean(),
      endCursor: z.string().nullable(),
    }),
  }),
})

const findQuietEscalations = defineAction("Find quiet customer escalations")
  .account("linear", {
    connections: [
      { connectionMethodId: "oauth", requiredScope: "read" },
      { connectionMethodId: "api-key" },
    ],
  })
  .input(
    z.object({
      teamId: z.string(),
      labelId: z.string(),
      quietDays: z.number().int().positive(),
      asOf: z.iso.datetime(),
    }),
  )
  .output(z.object({ total: z.number(), lines: z.array(z.string()) }))
  .retry({ replaySafety: "safe" })
  .handler(async ({ account, input }) => {
    const cutoff = Date.parse(input.asOf) - input.quietDays * 86_400_000
    const api = getLinearApi(account.secret)
    let cursor: string | undefined
    let total = 0
    const lines: string[] = []

    do {
      const { issues } = await api.graphql(
        `query QuietEscalations($first: Int!, $after: String, $filter: IssueFilter) {
          issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) {
            nodes { identifier title url updatedAt assignee { name } state { type } }
            pageInfo { hasNextPage endCursor }
          }
        }`,
        {
          responseSchema: pageSchema,
          variables: {
            first: 50,
            ...(cursor && { after: cursor }),
            filter: {
              team: { id: { eq: input.teamId } },
              labels: { some: { id: { eq: input.labelId } } },
            },
          },
        },
      )
      for (const issue of issues.nodes) {
        if (
          issue.state.type === "completed" ||
          issue.state.type === "canceled" ||
          Date.parse(issue.updatedAt) > cutoff
        )
          continue
        total++
        if (lines.length < 30) {
          lines.push(
            `• ${issue.identifier} — ${issue.title} (${issue.assignee?.name ?? "unassigned"}): ${issue.url}`,
          )
        }
      }
      if (!issues.pageInfo.hasNextPage) break
      cursor = issues.pageInfo.endCursor ?? undefined
      if (!cursor)
        throw new Error("Linear returned another page without a cursor")
    } while (true)

    return { total, lines }
  })

export default automation(
  "Surface customer escalations that have gone quiet",
  {
    parameters: [
      { label: "Linear team ID", name: "linearTeamId", type: "text" },
      { label: "Escalation label ID", name: "escalationLabelId", type: "text" },
      { label: "Quiet days before alert", name: "quietDays", type: "text" },
      {
        label: "Slack escalation channel ID",
        name: "slackChannelId",
        type: "text",
      },
    ],
  },
  ({ parameters }) => {
    const quietDays = z.coerce
      .number()
      .int()
      .positive()
      .parse(parameters.quietDays)
    const tick = onSchedule({ schedule: "0 9 * * 1-5", timeZone: "UTC" })
    const stalled = findQuietEscalations({
      teamId: parameters.linearTeamId,
      labelId: parameters.escalationLabelId,
      quietDays,
      asOf: tick.scheduledAt.transform((date) => date.toISOString()),
    }).filter(({ total }) => total > 0)

    slack.sendMessage({
      conversation: parameters.slackChannelId,
      text: t`${stalled.total} customer escalations have had no Linear update for ${quietDays} days. Showing the first 30:\n${stalled.lines.transform((lines) => lines.join("\n"))}\nAsk the issue owner for a status and update the customer when you have one.`.transform(
        escapeSlackText,
      ),
      unfurlLinks: false,
    })
  },
)

/** Keeps provider text from becoming Slack mentions or control markup. */
function escapeSlackText(text: string) {
  return text
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
}

What a run looks like

Example input

A labeled, open escalation has not been updated in Linear for four days; the threshold is three days.

Expected result

The next weekday digest lists the issue, assignee, and direct Linear link for follow-up.

Set it up

  1. Copy the setup prompt at the top into your coding agent.
  2. Choose the Linear team, escalation label, quiet-days threshold, and Slack destination with the setup agent.
  3. Authorize Linear and a paid Slack workspace. Compare the first run with the same labeled issues in Linear.

Check the result

Run against a test escalation last updated beyond the threshold and a newer one. Confirm only the quiet issue appears and the link opens correctly.

Where this workflow stops

  • The Slack message shows the first 30 quiet issues and the full count. Use the matching Linear view to review the rest when the queue is larger.
  • A Linear update does not necessarily mean the customer received an update. This is an internal prompt to check status, not proof of communication.

Common questions

Will it message customers?

No. It sends an internal digest so a teammate can check the issue and decide what to tell the customer.

Can I use a different quiet period?

Yes. The agent asks how many quiet days should trigger an alert. You can change that setting later.

More automation examples