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
- Defining workflows —
workflow,payload,success, default execute factories, and checkpointed steps - Retries & failure handling — per-step retries, failed runs, retry controls, and compensation
- Sleep & wakeups —
sleep, schedules, andawaitSignal - Clustering — multi-instance workflow distribution and schedule coordination
- Flow control — durable queues, bounded concurrency, and rate limits
- Versioning — definition versions, compatibility metadata, and patch markers
- Debugging & inspection — dashboard, inspect endpoints, CLI, tables, and tests
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 buildExecutestep({...}) 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
AppContextbuilt for the run. Starts from RPC handlers, mutations, actions, schedules, verified incoming handlers, and inspect tooling record asourcesuch asworkflow-rpc,app-context,schedule:<name>,incoming:<id>, orinspect. 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 asourceofevent:<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(...)receivecontext.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, andpatchesmetadata 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.