Recipe: incident escalation

Triage an alert with an AI agent, fan out notifications for the serious ones, and wait for a human to acknowledge.

What it does: an alert arrives, an agent() step classifies its severity, and critical alerts fan out to parallel notifications and then park on a form until a human acknowledges — while low-severity alerts auto-resolve.

When to use it: on-call triage, alert routing, anything where a judgement call decides whether to wake a human.

Requires Claude Code channels

This recipe uses an agent() step, which is answered by the driving Claude Code session. Set up channels and start the run with —channel <id>, or the run will park at WAITING_AGENT with no one to answer.

Flow

receive ─▶ [agent: triage → {severity, summary}] ─▶ route
                                                       ├─ critical/high ─▶ notify (parallel: slack + email) ─▶ await_ack (form) ─▶ resolve
                                                       └─ else ──────────▶ auto_resolve ─▶ resolve

The workflow

// Incident escalation — triage an alert, escalate the serious ones to a human.
//
// An alert comes in; an agent() classifies its severity; criticals fan out to
// parallel notifications and then wait for a human to acknowledge, while low-severity
// alerts are auto-resolved. Demonstrates agent(), branches, parallel, and a form.
//
// REQUIRES Claude Code channels: the agent() step is answered by the driving Claude
// session, so start the run from Claude Code with --channel <id>. See docs → channels.
//
// Connections used: slack (generic-http or MCP), composio_gmail.
//
//   flowctl def create --file examples/workflows/incident-escalation.js
//   flowctl run start --workflow IncidentEscalation --channel <id> \
//     --input '{"alert_id":"A-12","text":"DB connections exhausted"}' -q

function receive(ctx) {
  return { received: true };
}

function on_triage(ctx, data) {
  return { severity: data.result.severity, summary: data.result.summary };
}

function route(ctx) { return {}; }

function notify_slack_req(ctx) {
  return { body: { channel: "#incidents", text: "[" + ctx.vars.severity + "] " + ctx.vars.summary } };
}
function notify_slack_resp(ctx, response) { return { slack_ok: response.status_code < 300 }; }

function notify_email_req(ctx) {
  return { recipient_email: "oncall@example.com", subject: "Incident " + ctx.vars.alert_id, body: ctx.vars.summary };
}
function notify_email_resp(ctx, response) { return { email_ok: response.successful === true }; }

function fanout(ctx) { return {}; }

function on_ack(ctx, data) {
  return { acknowledged: true, ack_by: data.ack_by || "unknown" };
}

function auto_resolve(ctx) { return { resolution: "auto-resolved (low severity)" }; }
function resolve(ctx) { return { status: "resolved" }; }

workflow({
  name: "IncidentEscalation",
  version: "1",
  description: "Triage an alert and escalate criticals to a human.",
  initial: { alert_id: null, text: "", severity: null, summary: null, acknowledged: false },
  triggers: [apiTrigger("alert"), webhookTrigger("alert_hook")],
  steps: [
    step("receive", { start: true, next: "triage", label: "Receive alert" }, receive),

    agent("triage", {
      prompt: "Classify this alert and summarize it in one line. Alert: {{text}}",
      resultType: "json",
      context: ["text", "alert_id"],
      resultSchema: {
        type: "object",
        required: ["severity", "summary"],
        properties: {
          severity: { type: "string", description: "one of: critical, high, low" },
          summary: { type: "string" },
        },
      },
      next: "route",
    }, on_triage),

    step("route", {
      branches: [
        branch("severity == 'critical'", "notify"),
        branch("severity == 'high'", "notify"),
        branch("true", "auto_resolve"),
      ],
    }, route),

    step("notify", {
      parallel: { steps: ["notify_slack", "notify_email"], next: "await_ack" },
    }, fanout),

    connector("notify_slack", {
      connection: "slack",
      operation: "post_message",
      instance: "default",
      requestMapper: notify_slack_req,
      responseCombiner: notify_slack_resp,
    }),
    connector("notify_email", {
      connection: "composio_gmail",
      operation: "send_email",
      instance: "my-gmail",
      requestMapper: notify_email_req,
      responseCombiner: notify_email_resp,
    }),

    form("await_ack", {
      title: "Acknowledge incident {{alert_id}}",
      schema: {
        type: "object",
        properties: { ack_by: { type: "string", description: "Who is taking this?" } },
        required: ["ack_by"],
      },
      next: "resolve",
    }, on_ack),

    step("auto_resolve", { next: "resolve", label: "Auto-resolve" }, auto_resolve),
    step("resolve", { next: "end", label: "Resolve" }, resolve),
  ],
  outcomeTemplates: {
    completed: "Incident {{alert_id}} ({{severity}}) — {{status}}.",
  },
  result: (vars) => ({ alert_id: vars.alert_id, severity: vars.severity, acknowledged: vars.acknowledged }),
});

Run it

flowctl def create --file examples/workflows/incident-escalation.js
flowctl run start --workflow IncidentEscalation --channel <id> \
  --input '{"alert_id":"A-12","text":"DB connections exhausted"}' -q
# critical alerts park on the ack form:
flowctl form submit <exec> await_ack --data '{"ack_by":"jane"}'

Variations

  • More channels. Add a PagerDuty or SMS connector to the notify parallel block.
  • Escalation timer. Loop the ack form back through a branch to re-notify if no one acknowledges.
  • Skip the human. For lower tiers, route straight to auto_resolve and just record the event.