Skip to content

Escalation ladder

Operational and regulated notifications have a property marketing emails don’t: someone has to act on them. A safety check is due, an incident is open, a compliance deadline is approaching — and if the responsible person doesn’t acknowledge in time, it has to escalate, not just sit in an inbox.

This example builds a generalized acknowledge-or-escalate ladder:

  1. Notify the responder.
  2. Wait for an acknowledged signal — for a bounded time.
  3. No acknowledgement before the timeout? Climb to the next rung (rising urgency, or a stronger channel).
  4. Run out of rungs? Record a breach and fire a final notice.

It’s nothing but the waitForEvent building block in a loop — palumb ships no bespoke “escalation” primitive. The ladder is plain TypeScript you own and can shape to your domain.

Each rung notifies, then waits for the acknowledgement. The decision to climb is driven by whether the inbound signal arrived — never by palumb polling your backend (events-in / sends-out).

import type { SendContent, StepTools } from '@palumb/sdk/bridge';
interface EscalationRung {
id: string; // unique, stable step-id segment
channel: string; // 'email' today; 'sms'/'slack' later
waitFor: number | string; // ack window — seconds or '15 minutes'
to?: string; // who to notify; omit = the run's subscriber
render: (ctx: { rung: number }) => SendContent;
}
type EscalationResult = { acknowledged: boolean; rung?: string; by?: string };
async function runEscalation(
step: StepTools,
opts: {
rungs: EscalationRung[];
ackEvent?: string; // signal that counts as ack (default 'acknowledged')
onExhausted?: () => SendContent; // optional final breach notice
},
): Promise<EscalationResult> {
const ackEvent = opts.ackEvent ?? 'acknowledged';
for (let i = 0; i < opts.rungs.length; i++) {
const rung = opts.rungs[i];
await step.send(`notify_${rung.id}`, rung.channel, () => rung.render({ rung: i + 1 }), {
to: rung.to, // route this rung to a specific responder
});
const ack = await step.waitForEvent<{ by?: string }>(`ack_${rung.id}`, {
event: ackEvent,
timeout: rung.waitFor,
});
if (ack) {
await step.run(`resolved_${rung.id}`, async () => ({ acknowledged: true, rung: rung.id }));
return { acknowledged: true, rung: rung.id, by: ack.by };
}
}
if (opts.onExhausted) {
await step.send('notify_breach', opts.rungs[0]?.channel ?? 'email', opts.onExhausted);
}
await step.run('breachRecorded', async () => ({ acknowledged: false }));
return { acknowledged: false };
}

Every step.* call is durable: completed rungs are replayed from history, never re-run, so the loop survives restarts and waits of any length.

import { workflow } from '@palumb/sdk/bridge';
const incidentEscalation = workflow('incidentEscalation', async ({ payload, step }) => {
const task = String(payload.task ?? 'a compliance task');
// The run is triggered FOR the operator, so rung 1 omits `to`. The higher
// rungs route to their own responder — ids the tenant passes in the payload
// (you own the org chart; no runtime pull).
await runEscalation(step, {
rungs: [
{ id: 'operator', channel: 'email', waitFor: '15 minutes',
render: () => ({ subject: `Action required: ${task}`,
body: `<p><b>${task}</b> is due. Please acknowledge to close it out.</p>` }) },
{ id: 'supervisor', channel: 'email', waitFor: '15 minutes', to: payload.supervisorId as string,
render: () => ({ subject: `Escalation: ${task} not acknowledged`,
body: `<p><b>${task}</b> has not been acknowledged by the operator. Please follow up.</p>` }) },
{ id: 'manager', channel: 'email', waitFor: '1 hour', to: payload.managerId as string,
render: () => ({ subject: `Escalation: ${task} still open`,
body: `<p><b>${task}</b> remains unacknowledged up the chain.</p>` }) },
],
onExhausted: () => ({ subject: `Unacknowledged: ${task}`,
body: `<p><b>${task}</b> was never acknowledged. Logged as a breach.</p>` }),
});
});

Start a run — the response carries the run id:

Terminal window
curl -sS "$PALUMB_BASE_URL/v1/events" \
-H "Authorization: Bearer $PALUMB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "workflowId": "incidentEscalation",
"to": { "subscriberId": "operator_1" },
"payload": { "task": "Daily fire-door inspection",
"supervisorId": "supervisor_1",
"managerId": "manager_1" } }'

When the responder acknowledges (clicks a link, hits a button in your app), your backend signals the waiting run with the run id:

Terminal window
curl -sS "$PALUMB_BASE_URL/v1/events/$RUN_ID/signal" \
-H "Authorization: Bearer $PALUMB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "event": "acknowledged", "data": { "by": "operator@acme.example" } }'

The run is suspended on exactly one rung at a time, so a single acknowledged signal resolves whichever rung is currently waiting and stops the climb. If no signal arrives before the last rung’s timeout, the workflow records the breach.

flowchart TD
    T["POST /v1/events<br/>incidentEscalation"]
    R1["rung 1 · notify operator<br/>waitForEvent('acknowledged', 15m)"]
    R2["rung 2 · notify supervisor<br/>waitForEvent('acknowledged', 15m)"]
    R3["rung 3 · notify manager<br/>waitForEvent('acknowledged', 1h)"]
    B["breach · final notice + record"]
    A(("acknowledged"))

    T --> R1
    R1 -- timeout --> R2
    R2 -- timeout --> R3
    R3 -- timeout --> B
    R1 -- "signal" --> A
    R2 -- "signal" --> A
    R3 -- "signal" --> A

By default a run notifies the subscriber it was triggered for. Pass step.send(.., { to }) to direct a rung at a different subscriber — that’s how the supervisor and manager rungs above reach their own inbox instead of the operator’s. The recipient is any subscriber of your tenant (palumb resolves it within the tenant; an unknown id fails the send), so one run can walk a real responder chain — operator → supervisor → manager — with a single shared acknowledgement: whoever acks first stops the whole climb.

You decide who each rung targets — here the ids come straight from the trigger payload (supervisorId, managerId), keeping palumb out of your org chart (events-in / sends-out). You can equally escalate the channel to the same person (email → later smsslack) by changing channel instead of to.

The timeouts above (15 minutes, 1 hour) are real durations the engine parses; use short ones ('20 seconds') only when exercising the flow locally. palumb holds the suspended run durably for the whole window — there is no cost to a long wait and nothing to keep alive on your side.