Agora
Concepts

Sleep & signals

Pause a workflow durably — ctx.sleep for time-based waits (minutes to months, no compute), ctx.waitForSignal for human approvals and webhooks, and ctx.waitForEvent for name-based pub/sub with reliable (buffered) delivery, all surviving restarts.

Real workflows wait — for a delay, or for the outside world. Both kinds of pause are durable: the run is suspended (no compute consumed) and resumes on its own, even across restarts.

1async run(ctx: WorkflowCtx, order: Order) {2  await ctx.step(this.orders.place, order)3 4  // durable timer — suspend 2h, survives restarts:5  await ctx.sleep('2h')6 7  // or wait for an external signal (webhook, approval):8  const approval = await ctx.waitForSignal(`approved:${order.id}`)9  await ctx.step(this.orders.finalize, { order, approval })10}
run settles — completedplacesleep 2hwaitingsignal →finalizedone
completesThe body returns; the run completes.
6 / 6

Durable sleep

ctx.sleep(duration) suspends the run until the timer is due. Accepts a duration string ('30s', '2h', '7 days', '7d', '1w') or milliseconds:

await ctx.localStep('draft', () => generate(topic))
await ctx.sleep('7 days') // gather signals for a week — nothing running
await ctx.localStep('refine', () => refine(topic))

Under the hood the run becomes suspended with a wakeAt; a poller (the durable:work loop, via engine.resumeDueTimers()) resumes runs whose timer has come due. On resume, the steps before the sleep replay from their checkpoints.

To wait until a specific moment instead of for a duration, use ctx.sleepUntil(when) — it takes a Date or an epoch-ms number:

await ctx.sleepUntil(new Date('2026-12-31T00:00:00Z'))

Capture the target time deterministically — derive it from a checkpointed value (a step result, ctx.now()), never from a raw Date.now() in the body. See Versioning & determinism.

Signals (human-in-the-loop)

ctx.waitForSignal(token) suspends the run — with no timer — until an external engine.signal(token, payload) arrives. Perfect for approvals, webhooks, or any third-party callback.

const decision = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
if (!decision.approved) return { status: 'rejected' }

Deliver the signal from anywhere — typically a controller — via engine.signal:

app/controllers/approvals_controller.ts
async approve({ params, request, response }: HttpContext) {
  await this.engine.signal(`approve:${params.id}`, request.body()) // resumes the run with the payload
  return response.noContent()
}

The payload is checkpointed like any step result, so the resumed run sees the same decision even if it replays again later.

Pass { timeoutMs } to bound the wait — if the deadline passes before a signal arrives, waitForSignal throws SignalTimeoutError, which you can catch to take a default branch:

try {
  await ctx.waitForSignal('approve', { timeoutMs: 24 * 60 * 60 * 1000 })
} catch (err) {
  if (err instanceof SignalTimeoutError) return { status: 'expired' }
  throw err
}

Signal buffering & signal-with-start

A signal sent before the run has reached its waitForSignal is buffered, not lost — the next waitForSignal for that token consumes it. This holds even under the tight race where the sender and the waiter arrive at almost the same instant: delivery uses a take → buffer → re-check sandwich on the send side that pairs with a register → re-check on the wait side, so a waiter that registers in the sliver between a "nobody's waiting" miss and the buffer write is still resumed instead of both rows being stranded (the lost-wake window is closed).

And engine.signalWithStart(workflow, input, runId, { token, payload }) starts a run if it doesn't exist and delivers a signal to it in one race-free call — ideal for an event-sourced entity that's driven entirely by incoming events:

// Each event starts the run if needed, then delivers; ordering and exactly-once are preserved.
await engine.signalWithStart('counter', {}, 'k1', { token: 'add:k1', payload: 10 })
await engine.signalWithStart('counter', {}, 'k1', { token: 'add:k1', payload: 20 })

Writing a custom StateStore? Reliable delivery needs the store to expose removeSignalWaiter(waiter) — an exact-match delete (token + runId + seq must all match) that a waiter uses to retract its own registration after resolving some other way (a buffered hit, a timeout). It is distinct from takeSignalWaiter(token), which deletes any row for the token; a blind take there could steal a different run's waiter that has since claimed the same token. The bundled Lucid and in-memory stores already implement it.

Named events

A signal is point-to-point: one token, one waiter. A named event is name-based pub/sub — one engine.publishEvent(name, payload) fans out to every run parked on that name, filtered by an optional match. Use it when several runs (or none yet) care about the same real-world fact.

Inside a workflow, ctx.waitForEvent(name, { match }) suspends the run — no compute — until a matching event is published. match is a subset of the payload that must deep-equal, so a publish only wakes the runs it concerns:

// Each order-run waits only for ITS payment to settle:
const settled = await ctx.waitForEvent<{ orderId: string; amount: number }>(
  'payment.settled',
  { match: { orderId: order.id } },
)

Publish from anywhere — a controller, a webhook, another workflow — via the engine. publishEvent returns how many live recipients it reached:

app/controllers/payments_controller.ts
async settled({ request, response }: HttpContext) {
  const body = request.body() // { orderId, amount }
  await this.engine.publishEvent('payment.settled', body) // wakes every matching waiter
  return response.noContent()
}

A publish can also start runs: register a workflow with onEvent: [name] and each publish of that name starts a fresh run of it, passing the payload as input.

engine.register('fraud-check', '1', async (ctx, payment) => { /* … */ }, {
  onEvent: ['payment.settled'], // every publish starts a new fraud-check run
})

match also bounds a wait in time — pass { timeoutMs }, and if the deadline passes first the call throws SignalTimeoutError, exactly like waitForSignal.

Idempotent publishing — opts.id

An upstream that delivers at-least-once will publish the same real-world fact twice, and by default each publish starts its own onEvent run. Give the publish the upstream's own event id and the second delivery becomes a no-op:

app/controllers/webhooks_controller.ts
async paymentSettled({ request, response }: HttpContext) {
  const event = request.body() as { id: string; data: Payment }

  // The provider's event id — redelivering it starts no second run.
  await this.engine.publishEvent('payment.settled', event.data, { id: event.id })

  return response.ok({})
}

The id becomes part of the run id each subscriber is started with, and starting a run id twice is a no-op — so the guarantee is the same one that makes scheduling and webhooks idempotent. Omit it and each publish gets a fresh id, meaning each publish triggers.

Two limits worth knowing: it deduplicates the onEvent start half only, not the fan-out to runs already parked on ctx.waitForEvent; and the buffered copy is stored under its own id, so a publish that reached nobody is still buffered.

Coalescing bursty triggers — eventBatch

onEvent starts one run per publish, which is wrong for a chatty source. A hundred activity events in a minute should not become a hundred runs.

eventBatch coalesces them, in one of two modes:

// Debounce: one run with the LAST payload, once the source goes quiet for 5s.
engine.register('reindex', '1', async (ctx, activity) => { /* … */ }, {
  onEvent: ['activity'],
  eventBatch: { mode: 'debounce', windowMs: 5_000 },
})

// Batch: one run per 50 events, or per minute, whichever comes first.
engine.register('flush-metrics', '1', async (ctx, input) => {
  const { events } = input as { events: MetricEvent[] }
  /* … */
}, {
  onEvent: ['metric'],
  eventBatch: { mode: 'batch', maxSize: 50, windowMs: 60_000 },
})

The two modes differ in what the run receives, and that is the thing to decide on:

ModeFires whenInput to the run
debouncethe source has been quiet for windowMsthe last payload — earlier ones are discarded
batchmaxSize events accumulate, or windowMs elapses from the first{ events: [...] } — every payload, in order

Pick debounce when only the latest state matters (reindex a document that was edited eleven times). Pick batch when every event is a fact you must not lose (flush metrics, write an audit trail).

The accumulation itself runs as a long-lived durable workflow, so the pending window survives a restart rather than losing the events buffered in it. Note that a publish routed into an accumulator counts as delivered, so it is never additionally buffered.

Reliable (buffered) events

A publish that reaches no live recipient — no waitForEvent matched and no onEvent subscriber exists — is buffered rather than silently dropped: exactly one copy is kept, and the first future ctx.waitForEvent(name, { match }) whose match accepts its payload consumes it. Redelivery is point-to-point — the buffered copy goes to one waiter, never to a later-registered onEvent subscriber. This is the events analog of signal buffering, and like signals it survives the register/publish race in both directions.

This is the default. Opt out per-publish with { buffer: false } when a publish that nobody's listening for should just be dropped (a live-only broadcast):

// Buffered (default): safe even if the order-run hasn't reached waitForEvent yet.
await engine.publishEvent('payment.settled', { orderId, amount })

// Live-only: fan out to whoever is waiting right now, drop if nobody is.
await engine.publishEvent('metrics.tick', { at: Date.now() }, { buffer: false })

Buffered events are keyed by event name (many waiters can share a name with different match), so consumption is list → evaluate the waiter's own match → claim: a non-matching buffered event never blocks an earlier matching one behind it. On the Lucid store the copies live in the durable_buffered_events table.

Because a buffered event holds only one copy per publish and is claimed by the first matching waiter, events are point-to-point on redelivery — not a durable broadcast log. If you need every future subscriber to see a past event, model it as workflow state (a run started with onEvent) rather than relying on the buffer.

For a validated, rejectable steering primitive (Temporal-style updates) and a side-effect-free way to read a live run's state, see Queries & updates. For the deterministic callback-URL pattern, see Durable webhooks.

On this page