Skip to content

Sending messages

A workflow sends a message by declaring a step.send — never by talking to a provider directly. You render the content; palumb resolves the channel and the recipient, decrypts the credentials, and makes the real call.

await step.send('welcome', 'email', () => ({
subject: 'Welcome!',
body: '<p>Hi there</p>',
}));
step.send(
stepId: string,
channelKind: string,
render: () => SendContent | Promise<SendContent>,
options?: { to?: string },
): Promise<SendResult>
  • stepId — a label unique within the run. It’s what makes the send a durable, idempotent step (see below).
  • channelKind — the channel kind, e.g. 'email'. Not a provider — palumb picks your enabled provider for that kind (lowest priority wins).
  • render — a callback returning the already-rendered content. It can be async (fetch a template, format numbers). The recipient is the run’s subscriber — you don’t pass it.
  • options.to — an optional recipient override.
interface SendContent { subject?: string; body: string }
interface SendResult { attemptId: string; status: string; providerMessageId?: string }

subject is optional (an email uses it; a chat kind may not). body is the rendered message. The returned SendResult carries the DeliveryAttempt id and its status.

The callback closes over the workflow’s payload and subscriber, so you can personalize:

const orderShipped = workflow('orderShipped', async ({ payload, subscriber, step }) => {
const name = subscriber?.subscriberId ?? 'there';
await step.send('shipped', 'email', () => ({
subject: `Your order ${payload.orderId} shipped`,
body: `<p>Hi ${name}, it's on the way!</p>`,
}));
});

Render inside the callback, not in the bare workflow body — the body re-runs on every step, while the callback runs only when the step actually executes.

Each step.send is a durable step. The engine records its result and derives a stable idempotency key per step, enforced by a unique constraint on the egress ledger. So if the step is retried — a transient provider error, a crash and resume — palumb finds the prior attempt and, if it already sent, short-circuits instead of re-sending. Retries never double-deliver. (More in Delivery.)

By default a send goes to the run’s subscriber. Pass { to: '<subscriberId>' } to deliver to a different subscriber of your tenant instead:

// Alert a supervisor about the run's subscriber:
await step.send('alertSupervisor', 'email',
() => ({ subject: 'Escalation', body: '<p>No ack — escalating.</p>' }),
{ to: 'supervisor_1' });

palumb resolves the override within your tenant; an unknown id fails the send. This is what lets a single run notify several people — the backbone of the escalation ladder.

step.send maps provider/config outcomes onto the engine’s retry behavior:

  • 4xx — terminal. No enabled channel for the kind, or no contact address for the subscriber. Retrying can’t fix it, so it surfaces as a terminal failure and the step is not retried.
  • 5xx / network — transient. Surfaced as a normal error so the engine retries the durable step; the idempotency key keeps retries safe.

You generally don’t catch these — let them propagate so the engine applies the right policy. See Architecture › Failure behavior.