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.
The skeleton
Section titled “The skeleton”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, fn, options?)
Section titled “workflow(id, fn, options?)”workflow( id: string, fn: (ctx: { payload; subscriber?; step }) => Promise<void>, options?: { schedule?: string; payloadSchema?: object },): WorkflowDefinitionidis theworkflowIda 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.fnis 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 withPOST /v1/sync(below). Omit it for event-triggered workflows.options.payloadSchema(optional, reserved) — a JSON Schema for the trigger payload.
The fn signature
Section titled “The fn signature”async ({ payload, subscriber, step }) => { /* ... */ }| Field | Type | What it is |
|---|---|---|
payload | Record<string, unknown> | The trigger payload, forwarded verbatim |
subscriber | { subscriberId: string; … } (optional) | The single recipient this run is for |
step | StepTools | The 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.
The step toolbox
Section titled “The step toolbox”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.
step.run(stepId, callback)
Section titled “step.run(stepId, callback)”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>' }));step.delay(stepId, options)
Section titled “step.delay(stepId, options)”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 });step.waitForEvent(stepId, options)
Section titled “step.waitForEvent(stepId, options)”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.
Serving + connecting
Section titled “Serving + connecting”-
Collect your workflows.
new BridgeClient().register([...])returns the client (chainable):const bridge = new BridgeClient().register([orderCreated, onboarding]); -
Serve one endpoint.
serveNest(bridge, { signingKey })returns a handler you mount on a single route. ThesigningKeyis the secret palumb returned fromPUT /v1/bridge. palumb signs every call asHMAC-SHA256("${timestamp}.${rawBody}")and sends it inx-palumb-signature(withx-palumb-timestamp);serveNestverifies it against the raw body and rejects anything stale (default tolerance 300 s) or unsigned with401. -
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. -
Sync schedules (only if you use
options.schedule).POST /v1/syncdiscovers your workflows from the Bridge and arms crons for the schedule-triggered ones.
What the Bridge answers
Section titled “What the Bridge answers”serveNest handles palumb’s signed requests and maps failures to clear status
codes:
| Situation | Status |
|---|---|
| Bad / missing / stale signature | 401 |
| Malformed JSON body | 400 |
| Unknown workflow id | 404 |
| Step callback threw | 500 (the engine retries the durable step) |
Related
Section titled “Related”- Triggers — what starts your workflow.
- Sending messages —
step.sendin depth. - SDK API —
workflow,step.*,serveNest, types. - Example: Welcome email