Agora

Getting Started

Run your first durable workflow in an AdonisJS app — install, configure, register a workflow, and start a run. Zero infrastructure with the in-process transport and in-memory store.

This guide gets a workflow running in an AdonisJS app in a few minutes — no broker, no database, using the in-process transport and an in-memory store. Swap those for the queue transport and the Lucid store when you're ready for production.

1. Install & configure

node ace add @adonis-agora/durable

add installs the package and runs its configure hook, which:

  • registers @adonis-agora/durable/durable_provider and @adonis-agora/durable/dashboard_provider in adonisrc.ts;
  • registers the Ace commands (@adonis-agora/durable/commands) — see the CLI;
  • registers the Assembler init hooks that generate the typed app/workflows / app/steps barrels — see codegen;
  • adds the #workflows/* and #steps/* subpath imports to your package.json;
  • publishes config/durable.ts, config/durable_dashboard.ts and the Lucid migrations for the optional lucid store and db transport.

The published config/durable.ts defaults to the in-process memory transport + the in-memory store, so the engine works with zero extra infrastructure. Drivers are selected by name from the transports / stores maps — each lazily imports its optional peer (@adonisjs/queue, @adonisjs/lucid, bullmq) only when chosen:

config/durable.ts
import { defineConfig, transports } from '@adonis-agora/durable'

export default defineConfig({
  transport: 'memory',
  transports: {
    memory: transports.memory(),
    // 'event-emitter': transports.eventEmitter(), // single-process production, no infra
    // queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
    // db: transports.db(),
    // bullmq: transports.bullmq({ connection: { host: '127.0.0.1', port: 6379 } }),
  },
  // store: 'lucid',
  // stores: { lucid: stores.lucid() },
  // leaseMs: 30_000,
})

memory is test-only

The memory transport drives a dispatch straight into its handler and exists for tests and this tutorial. For a single-process production app with no external infrastructure, select transports.eventEmitter(); for several processes, pick a cross-process transport and the Lucid store.

The provider binds a singleton WorkflowEngine built from this config. Import it anywhere — a controller, a service, a start file — with import engine from '@adonis-agora/durable/services/main'. On a store-less tenant pod there is no engine; the same module exports runGateway, the role-agnostic read/control/start surface.

2. Define a step (optional)

ctx.step(refOrName, input) is the durable step primitive — always dispatched, routed to a handler by name. Author the handler as a @Step class under app/steps, or with defineStep for a typed ref. Schemas are optional — add input/output from any Standard Schema library (zod, valibot, arktype, …) to validate at the serve boundary; the handler's input type is then inferred from the schema:

app/steps/charge.ts
import { defineStep } from '@adonis-agora/durable'
import { z } from 'zod' // or valibot, arktype, … — any Standard Schema

export const chargeCard = defineStep(
  'payments:charge-card',
  async (input) => ({ chargeId: `ch_${input.orderId}` }),
  {
    input: z.object({ orderId: z.number().int(), amountCents: z.number().int() }),
    output: z.object({ chargeId: z.string() }),
    retries: 3,
  },
)

3. Define the workflow

A workflow is a class under app/workflows/ that extends BaseWorkflow and declares its identity with a static workflow = { name, version }. Its run(ctx, input) method is the deterministic body. The durable provider scans app/workflows/ at boot and registers every exported workflow class automatically — no manual engine.register(...), no preload to wire:

app/workflows/checkout_workflow.ts
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
import { chargeCard } from '#steps/charge'

export default class CheckoutWorkflow extends BaseWorkflow {
  static workflow = { name: 'checkout', version: '1' }

  async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
    await ctx.localStep('reserveStock', async () => ({ reserved: true }))
    const charge = await ctx.step(chargeCard, { orderId: order.id, amountCents: order.total })
    const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
    if (!approval.approved) return { status: 'rejected', chargeId: charge.chargeId }
    await ctx.localStep('ship', async () => ({ shipped: true }))
    return { status: 'shipped', chargeId: charge.chargeId }
  }
}

Lower-level alternative. You can still register by hand from a preload file with engine.register('checkout', '1', async (ctx, order) => { … }) — the body is the same deterministic (ctx, input) => …. Auto-discovery is the recommended path for same-runtime TypeScript workflows; see app/workflows.

4. Serve the step handler

A step is dispatched to a handler served under its name. A @Step class or defineStep(...) under app/steps is discovered and served automatically at boot — nothing else to wire. The low-level escape hatch is transport.handle(name, fn), which registers a handler directly on a worker-side transport.

For real cross-process handlers, configure the queue transport in config/durable.ts and run a worker process that serves the same step names. The workflow code never changes.

5. Start a run

A controller kicks off the workflow with the class's dispatch static — CheckoutWorkflow.dispatch(input) enqueues a top-level run and returns { runId } immediately, so the HTTP handler never blocks on workflow logic (no engine to inject for the happy path). Validate the request with VineJS, persist the order, then dispatch the run keyed by a stable runId. A worker executes the body (by default, the same instance, asynchronously), runs the steps, then suspends on the approval signal; an approval webhook resumes it:

app/validators/checkout.ts
import vine from '@vinejs/vine'

export const createCheckoutValidator = vine.compile(
  vine.object({
    total: vine.number().positive(),
  })
)
app/controllers/checkout_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import engine from '@adonis-agora/durable/services/main'
import CheckoutWorkflow from '#workflows/checkout_workflow'
import Order from '#models/order'
import { createCheckoutValidator } from '#validators/checkout'

export default class CheckoutController {
  // the engine is only needed to signal the run; dispatch resolves it for you
  async store({ request, response }: HttpContext) {
    const { total } = await request.validateUsing(createCheckoutValidator)
    const order = await Order.create({ total, status: 'pending' })

    // Enqueue the run and respond now — a worker runs the body.
    const { runId } = await CheckoutWorkflow.dispatch(
      { id: order.id, total: order.total },
      { runId: `checkout:${order.id}` }
    )

    return response.accepted({ runId })
  }

  // Later, from your approval webhook — this completes & ships the run.
  async approve({ params, response }: HttpContext) {
    await engine.signal(`approve:${params.id}`, { approved: true })
    return response.noContent()
  }
}

Wire the routes in start/routes.ts:

start/routes.ts
import router from '@adonisjs/core/services/router'

const CheckoutController = () => import('#controllers/checkout_controller')

router.post('checkout', [CheckoutController, 'store'])
router.post('checkout/:id/approve', [CheckoutController, 'approve'])

CheckoutWorkflow.dispatch(input, { runId }) is the ergonomic equivalent of engine.start(CheckoutWorkflow, input, runId) (a random UUID when you omit runId), and .start(input) equals that followed by await engine.waitForRun(runId, { terminal: true }), returning the run's output. The runId acts as the idempotency key, so dispatching the same one twice is a no-op. Called inside a running workflow, the same statics start a child instead (.start → ctx.child, .dispatch → ctx.startChild) — see child workflows.

Need the outcome inline? CheckoutWorkflow.start(input) blocks the caller until the run reaches a terminal state (completed/failed/cancelled/dead) — it keeps waiting through sleep/waitForSignal suspensions — and returns the workflow's output:

const output = await CheckoutWorkflow.start({ id: order.id, total: order.total }) // resolves when the run is terminal

engine.waitForRun(runId) without { terminal: true } resolves earlier, once the run settles — a terminal state or suspended — and returns the full RunResult (status, output, error). Pass { timeoutMs } to bound the wait.

.start blocks the calling context until the run settles — great for scripts and short runs, but avoid it on a hot HTTP path for a long-running workflow. There, .dispatch and respond immediately, then read the outcome later (a webhook, a poll, or engine.waitForRun(runId)).

That's it — a durable workflow whose dispatched step runs in-process, that pauses for human approval and survives restarts. Next:

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →