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>',}));The signature
Section titled “The signature”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 (lowestprioritywins).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.
Content & result shapes
Section titled “Content & result shapes”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.
Rendering with subscriber data
Section titled “Rendering with subscriber data”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.
Durable + idempotent
Section titled “Durable + idempotent”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.)
Sending to someone else
Section titled “Sending to someone else”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.
Terminal vs. transient failures
Section titled “Terminal vs. transient failures”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.
Related
Section titled “Related”- Delivery — what palumb does on the egress side.
- Channels — what
channelKindresolves to. - SDK API › step.send
- Example: Welcome email