Overview

Durable Effect workflows in Voltro — what they are, when to use them, and the current runtime boundaries.

A workflow in Voltro is a long-running Effect program that can survive process restarts. Durable work is expressed with workflow({...}), checkpointed step({...}) calls, durable sleep({...}), optional external awaitSignal(...) waits, and synchronous awaitUpdate(...) messages.

The implementation is @effect/workflow + @effect/cluster. No Restate, Temporal, Inngest, BullMQ, or separate worker runtime is required.

Live — start a 3-step durable workflow and watch its timeline stream as each step completes (durable-execution UI no other framework ships):

const run = useAction('app', 'demo.runPipeline')
const { runId } = await run.run({})
<WorkflowProgress api="app" runId={runId} />   {/* live step timeline */}

What's in this section

When to use a workflow

You have... Use
Multi-step job that must survive a deploy Workflow
External side effects that need retry/resume semantics Workflow
Human approval or webhook callback in the middle of work Workflow + awaitSignal
External command that must validate and return a result Workflow + awaitUpdate
Domain event that should fan out to durable work triggerWorkflow(...) + ctx.events.emit(...)
One-off durable delay inside an operation Workflow + sleep
Recurring job such as "daily at 3am" Schedule (*.cron.tsx)
Atomic database write Mutation
Request-scoped HTTP/email/payment call Action
Progressive server-to-client output Stream
Request-scoped LLM chat Agent or stream

The wedge: if the operation must survive the process, use a workflow. If it is just a short request, keep it in a mutation, action, query, or stream.

A minimal workflow

A workflow is split into two files paired by basename — a descriptor (*.workflow.tsx, browser-safe, imports @voltro/workflow/define) that the codegen pulls into the client rpcGroup, and a server executor (*.workflow.server.tsx) that holds the database/AI/cluster imports:

// apps/api/workflows/notes.summarise.workflow.tsx — descriptor (browser-safe)
import { workflow } from '@voltro/workflow/define'
import { Schema } from 'effect'

export const SummariseNote = workflow({
  name: 'notes.summarise',
  payload: { noteId: Schema.String },
  success: Schema.Struct({ summary: Schema.String }),
  idempotencyKey: ({ noteId }) => `notes.summarise:${noteId}`,
})
// apps/api/workflows/notes.summarise.workflow.server.tsx — executor (server-only)
import { step } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import type { AppContext } from '@voltro/runtime'

const NoteRow = Schema.Struct({ id: Schema.String, body: Schema.String })

const buildExecute = (ctx: AppContext) =>
  ({ noteId }: { noteId: string }) =>
    Effect.gen(function* () {
      const note = yield* step({
        name: 'load-note',
        input: { noteId },
        success: NoteRow,
        execute: Effect.tryPromise(
          () => ctx.store.select('notes').where('id', noteId).one(),
        ).pipe(Effect.flatMap(Schema.decodeUnknown(NoteRow)), Effect.orDie),
      })

      const summary = yield* step({
        name: 'summarise-with-llm',
        input: { noteId },
        success: Schema.String,
        execute: summariseWithLlm(note.body),
      })

      yield* step({
        name: 'save-summary',
        input: { noteId },
        success: Schema.Void,
        execute: Effect.tryPromise(
          () => ctx.store.update('notes', noteId, { summary }),
        ).pipe(Effect.asVoid, Effect.orDie),
      })

      return { summary }
    })

export default buildExecute

step({...}) is the checkpoint boundary. A plain yield* someEffect composes Effect logic, but it is not automatically recorded as a workflow step. Put external I/O, database writes, and expensive work inside step.

Note what the store calls do NOT do: no as never. Reach for the fluent builder (ctx.store.select(...)) rather than hand-building a query descriptor, and decode the untyped Row into the step's success schema with Schema.decodeUnknown instead of asserting it. .one() fails with the typed NoRowFound when the note is missing (or when more than one matches), so there is no rows[0] to null-check.

Starting a workflow

Every discovered *.workflow.tsx is emitted into rpcGroup.generated.ts as a unary RPC. The payload and error schemas come from the workflow, but the RPC success type is always a WorkflowRunHandle. Calling it starts durable work and returns that handle immediately:

import {
  useWorkflow,
  useWorkflowRun,
  useWorkflowRunEvents,
  useWorkflowRuns,
  useWorkflowRunSteps,
  useWorkflowUpdate,
} from '@voltro/client'

const summarise = useWorkflow<{ noteId: string }>(
  'app',
  'notes.summarise',
)

const run = await summarise.start({ noteId })
const { run: live } = useWorkflowRun('app', run.id)

run.id is the durable execution id and can be passed to useWorkflowRun(...). The hook subscribes to Voltro's built-in reactive workflow-run query, so the UI updates when _voltro_workflow_runs changes. Waiting for the success payload is explicit: use ctx.workflows.wait(...) on the server when another server-side operation really must block; otherwise render the live run row and let the workflow finish in the background.

For app-level job centers and detail pages, use the rest of the workflow hook family:

const { runs } = useWorkflowRuns('app', { tag: 'notes.summarise', limit: 25 })
const { steps } = useWorkflowRunSteps('app', live?.id)
const { events } = useWorkflowRunEvents('app', live?.id)
const approve = useWorkflowUpdate('app')
await approve.update({ id: live!.id }, 'approve', { decision: true })

Those hooks are backed by reactive framework tables too, so status, checkpointed steps, timers, signals, and update events flow through Voltro subscriptions instead of polling.

From server code, use ctx.workflows:

export default async ({ noteId }, ctx) => {
  await ctx.store.update('notes', noteId, { summaryStatus: 'queued' })
  const run = await ctx.workflows.start('notes.summarise', { noteId })
  return { runId: run.id }
}

Inside a mutation, ctx.workflows.start(...) is transaction-aware: Voltro computes the deterministic run handle immediately, queues the actual start, and only launches the workflow after the mutation commits. If the mutation rolls back, the workflow never starts. Actions and incoming webhooks start immediately after their own verification/idempotency work has succeeded.

Workflow controls use the same facade:

await ctx.workflows.signal({ id: runId }, 'approval', { approved: true })
const approved = await ctx.workflows.update({ id: runId }, 'approve', { decision: true })
await ctx.workflows.cancel('notes.summarise', executionId)
await ctx.workflows.resume('notes.summarise', executionId)
const snapshot = await ctx.workflows.query('notes.summarise', executionId)

Inside a workflow body, use ctx.workflows.child(...) for hierarchical work. Voltro records the parent execution id in _voltro_workflow_start_contexts before submitting the child run, so a different cluster runner still sees the same lineage when it starts executing the child.

const child = yield* Effect.promise(() =>
  ctx.workflows.child('orders.shipOne', { orderId }, {
    parentClosePolicy: 'cancel',
  }),
)

const childSnapshot = yield* Effect.promise(() => ctx.workflows.wait(child))

parentClosePolicy controls what happens when the parent closes. cancel is the default and interrupts an open child. terminate also interrupts the child and records the decision as a hard parent-close action. abandon leaves the child running. The dashboard shows child runs, their parent execution id, and the chosen policy.

Inside the workflow body, awaitUpdate(...) receives the tracked message, validates the payload, and records either update-completed with the result or update-failed with the validation/handler error:

import { awaitUpdate } from '@voltro/workflow'
import { Effect, Schema } from 'effect'

const result = yield* awaitUpdate(ctx, {
  name: 'approve',
  schema: Schema.Struct({ decision: Schema.Boolean }),
  success: Schema.Struct({ accepted: Schema.Boolean }),
  handle: ({ decision }) => Effect.succeed({ accepted: decision }),
})

Event-triggered workflows

Use *.trigger.ts files when durable work should start from domain events instead of a direct RPC or action call.

// apps/api/triggers/user.signup.trigger.ts
import { triggerWorkflow } from '@voltro/runtime'

export default triggerWorkflow<{ userId: string; plan: string }, { userId: string }>({
  event: 'user.signup',
  workflow: 'onboarding.start',
  filter: (event) => event.data.plan !== 'free',
  payload: (event) => ({ userId: event.data.userId }),
  idempotencyKey: (event) => event.id,
})

Then emit the event from a mutation, action, incoming webhook, schedule, or workflow:

await ctx.events.emit('user.signup', { userId, plan })

Voltro records the event in _voltro_workflow_events, records each trigger delivery in _voltro_workflow_event_deliveries, and starts matching workflows with source: event:<name>. Emits inside mutations are post-commit safe: if the mutation rolls back, the workflow fan-out is not launched.

Use event: '*' for audit-style wildcard triggers that should see every emitted domain event. The local devtools and Voltro Cloud workflow dashboards mirror domain events and delivery rows live in the Events tab, including filtered/skipped deliveries, failed fan-out, and the spawned execution id.

Runtime boundaries

  • The workflow executor receives an AppContext built for the run. Starts from RPC handlers, mutations, actions, schedules, verified incoming handlers, and inspect tooling record a source such as workflow-rpc, app-context, schedule:<name>, incoming:<id>, or inspect. Request starts also carry the starter trace; authenticated request starts carry the resolved subject into the run record and executor context.
  • ctx.events.emit(...) is available when the app declares workflow event triggers. Triggered workflow starts carry the starter subject/trace and a source of event:<name>.
  • Put tenant/user ids that the workflow must enforce into the payload. That keeps business authorization deterministic across retries, resumes, and future cross-replica handoff.
  • Child workflows should be started with ctx.workflows.child(...). Parent lineage and parent-close policy are persisted before the child start, so they survive cross-runner execution.
  • External incoming handlers created with defineIncomingWebhook(...) receive context.workflows, so a verified webhook can start, signal, or update a workflow after signature and idempotency checks.
  • Operational controls such as retry, cancel, suspend, resume, signal injection, and tracked updates also live on the dashboard, voltro workflows ..., and the inspect endpoints.

What is intentionally different from Temporal

  • Polyglot workers. Voltro workflows are TypeScript/Effect.
  • A separate workflow service. This is intentional: the engine is mounted by the framework alongside your API.
  • Automatic code-version isolation. A long-lived run resumed after a deploy executes the current workflow code. Use workflow version, compatibleWith, and patches metadata for deploy safety.

What you gain: workflows live beside queries, mutations, actions, streams, schedules, schema, and plugins. The same codegen and inspect surfaces see them.

Where to go next