Skip to content

Writing a workflow

Your workflows live in a Bridge — a small TypeScript service that exposes one signed HTTP endpoint. palumb’s engine calls that endpoint to run each step of a workflow; no Restate runs on your side and your code never holds provider credentials. You write plain TypeScript; durability lives in palumb.

This guide walks the whole shape: declaring a workflow, the step toolbox, and serving and connecting the Bridge.

import express, { type Request, type Response } from 'express';
import { workflow, BridgeClient, serveNest } from '@palumb/sdk/bridge';
const orderCreated = workflow('orderCreated', async ({ payload, subscriber, step }) => {
// payload: the trigger payload · subscriber: the single recipient
// each await step.* is a durable step
await step.send('confirm', 'email', () => ({
subject: `Order ${payload.orderId} received`,
body: `<p>Thanks for your order!</p>`,
}));
});
const bridge = new BridgeClient().register([orderCreated]);
const handle = serveNest(bridge, { signingKey: process.env.PALUMB_SIGNING_KEY! });
const app = express();
app.use(express.json({
// serveNest verifies the HMAC against the RAW body — capture it here.
verify: (req, _res, buf) => { (req as Request & { rawBody?: string }).rawBody = buf.toString('utf8'); },
}));
app.all('/api/palumb', (req: Request, res: Response) => handle(req as any, res as any));
app.listen(9080);
workflow(
id: string,
fn: (ctx: { payload; subscriber?; step }) => Promise<void>,
options?: { schedule?: string; payloadSchema?: object },
): WorkflowDefinition
  • id is the workflowId a trigger names. It must match ^([a-zA-Z]|_[a-zA-Z0-9])[a-zA-Z0-9_]*$ — letters, digits and underscores only, no dots or hyphens. An invalid id throws up front, before you ever serve it.
  • fn is your workflow body (see below).
  • options.schedule (optional) turns the workflow into a schedule-triggered one: a cron expression like '0 9 * * 1' (every Monday 09:00) makes palumb fire a fresh run per tick — the schedule is the trigger. Arm it with POST /v1/sync (below). Omit it for event-triggered workflows.
  • options.payloadSchema (optional, reserved) — a JSON Schema for the trigger payload.
async ({ payload, subscriber, step }) => { /* ... */ }
FieldTypeWhat it is
payloadRecord<string, unknown>The trigger payload, forwarded verbatim
subscriber{ subscriberId: string; … } (optional)The single recipient this run is for
stepStepToolsThe durable step toolbox

A run always sees exactly one subscriber — a topic fan-out starts one run per subscriber, never a loop inside your code.

Each step.* call is a durable step: its result is recorded by the engine and replayed (never re-run) on later invocations. Steps are serial — await them one at a time.

Run arbitrary tenant code as a durable step. The returned JSON is recorded and replayed; the callback runs at most once per run (make side effects idempotent — under failure it can be at-least-once).

const { token } = await step.run('mint', async () => ({ token: await mintToken() }));

step.send(stepId, channelKind, render, options?)

Section titled “step.send(stepId, channelKind, render, options?)”

Declare a delivery. palumb resolves your channel and the recipient and performs the physical send. See Sending messages for the full treatment, including the { to } recipient override.

await step.send('welcome', 'email', () => ({ subject: 'Welcome', body: '<p>Hi</p>' }));

Pause the run for a duration ({ seconds }) or until an instant ({ until }, an ISO string). No tenant code runs while waiting — palumb holds the run durably.

await step.delay('cooldown', { seconds: 3600 });

Suspend the run until an external event arrives (you signal it via POST /v1/events/{runId}/signal), or the optional timeout elapses. Returns the event payload, or null on timeout.

const engaged = await step.waitForEvent('awaitEngagement', {
event: 'userEngaged',
timeout: '3 days',
});
if (!engaged) await step.send('nudge', 'email', () => ({ subject: 'Still there?', body: '' }));

This is the foundation of durable, event-driven flows — see the Welcome email and Escalation ladder examples.

    1. Collect your workflows. new BridgeClient().register([...]) returns the client (chainable):

      const bridge = new BridgeClient().register([orderCreated, onboarding]);
    2. Serve one endpoint. serveNest(bridge, { signingKey }) returns a handler you mount on a single route. The signingKey is the secret palumb returned from PUT /v1/bridge. palumb signs every call as HMAC-SHA256("${timestamp}.${rawBody}") and sends it in x-palumb-signature (with x-palumb-timestamp); serveNest verifies it against the raw body and rejects anything stale (default tolerance 300 s) or unsigned with 401.

    3. Register the URL. Tell palumb where your Bridge lives:

      Terminal window
      curl -sS -X PUT "$PALUMB_BASE_URL/v1/bridge" \
      -H "Authorization: Bearer $PALUMB_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{ "webhookUrl": "https://app.example.com/api/palumb" }'

      The response carries the generated signing secret, once — store it as PALUMB_SIGNING_KEY.

    4. Sync schedules (only if you use options.schedule). POST /v1/sync discovers your workflows from the Bridge and arms crons for the schedule-triggered ones.

serveNest handles palumb’s signed requests and maps failures to clear status codes:

SituationStatus
Bad / missing / stale signature401
Malformed JSON body400
Unknown workflow id404
Step callback threw500 (the engine retries the durable step)