Defining workflows
The *.workflow.tsx convention, workflow schemas, execute factories, checkpointed steps, child workflows, and replay rules.
A workflow file is discovered by suffix: *.workflow.tsx. It exports one workflow({...}) definition and a default factory that receives AppContext and returns the workflow executor.
Anatomy
// apps/api/workflows/notes.summarise.workflow.tsx
import { workflow, step } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import type { AppContext } from '@voltro/runtime'
export const SummariseNote = workflow({
name: 'notes.summarise',
payload: { noteId: Schema.String },
success: Schema.Struct({ summary: Schema.String }),
idempotencyKey: ({ noteId }) => `notes.summarise:${noteId}`,
})
const buildExecute = (ctx: AppContext) =>
({ noteId }: { noteId: string }, _executionId: string) =>
Effect.gen(function* () {
const note = yield* step({
name: 'load-note',
input: { noteId },
success: Schema.Struct({ id: Schema.String, body: Schema.String }),
execute: Effect.tryPromise(() => loadNote(ctx.store, noteId)),
})
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(() => saveSummary(ctx.store, noteId, summary)),
})
return { summary }
})
export default buildExecuteTwo exports matter:
workflow({...})— the durable definition.payload,success, optionalerror, and optionalidempotencyKeyare read by codegen and the runtime.default— a build-execute factory,(ctx: AppContext) => (payload, executionId) => Effect. The CLI callsdefinition.toLayer(execute)during registration.
If the default export is not a function, discovery skips the file with a "missing default-export build-execute factory" warning.
Schema fields
export const ImportCustomers = workflow({
name: 'customers.import',
payload: {
uploadId: Schema.String,
dryRun: Schema.optional(Schema.Boolean),
},
success: Schema.Struct({
imported: Schema.Number,
skipped: Schema.Number,
}),
error: Schema.Union(InvalidCsv, ImportProviderDown),
idempotencyKey: ({ uploadId }) => `customers.import:${uploadId}`,
messages: {
signals: {
approval: Schema.Struct({ approved: Schema.Boolean }),
},
updates: {
approve: {
payload: Schema.Struct({ decision: Schema.Boolean }),
success: Schema.Struct({ accepted: Schema.Boolean }),
},
},
},
})payload is the start input. success is the resolved output. error is the typed failure channel. idempotencyKey deduplicates concurrent or repeated starts with the same logical input. messages is optional codegen metadata; it emits WorkflowSignals, WorkflowUpdates, and WorkflowMessages type maps, while runtime validation still happens at awaitSignal(...) / awaitUpdate(...).
There are exactly two message channels: signals (fire-and-forget) and updates (synchronous, with a result). A queries channel was declarable until 0.34.0 and never had a send path — nothing could invoke one — so it is gone. To read a run's state, write an ordinary *.query.ts over _voltro_workflow_runs / _voltro_workflow_run_steps; to ask a running workflow something and get an answer, use updates.
Step boundaries
step({...}) wraps @effect/workflow's Activity.make. It is the checkpointed unit that is journaled by the workflow engine and recorded into _voltro_workflow_run_steps.
const customer = yield* step({
name: 'fetch-customer',
input: { customerId },
success: Customer,
execute: Effect.tryPromise(() => crm.fetchCustomer(customerId)),
})The optional input field is not passed to the executor. It is persisted for inspection, truncated to a dashboard-safe size, and shown beside output/errors in the run timeline.
Plain Effect composition is still useful:
const normalised = normaliseCustomer(customer)
const enriched = yield* Effect.succeed(addDerivedFields(normalised))But it is not a durable activity boundary by itself. If it does external I/O, writes to storage, charges a card, sends an email, or calls an LLM, put that work inside step.
Deterministic replay
The workflow body can be replayed by the engine. The structure of checkpointed steps must be stable for the same payload.
OK:
const note = yield* step({ name: 'load-note', success: Note, execute: loadNote(noteId) })
if (note.archived) {
yield* step({ name: 'notify-archived', execute: notifyArchived(note.id) })
} else {
yield* step({ name: 'summarise', execute: summarise(note.body) })
}The branch depends on a checkpointed value.
Not OK:
if (Math.random() > 0.5) {
yield* step({ name: 'a', execute: doA })
} else {
yield* step({ name: 'b', execute: doB })
}If time, randomness, or external state influences structure, capture it in a step first:
const choice = yield* step({
name: 'choose-branch',
success: Schema.Boolean,
execute: Effect.sync(() => Math.random() > 0.5),
})Parallel steps
Steps with no data dependency run concurrently with plain Effect.all — no special API:
const [jira, github] = yield* Effect.all(
[
step({ name: 'fetch-jira', success: JiraIssues, execute: fetchJira(projectKey) }),
step({ name: 'fetch-github', success: GithubPrs, execute: fetchGithub(repo) }),
],
{ concurrency: 'unbounded' },
)Both steps journal independently, and the durable guarantees hold across the join:
- The steps genuinely overlap — one is not secretly serialized behind the other.
- On a retry or an operator redrive, a parallel step that already completed replays from its journal; only the sibling that failed re-executes.
Both properties are pinned by a contract test against the real engine (workflowParallelSteps.integration.test.ts), so an engine upgrade that broke either would go red rather than quietly serializing your fan-out.
Name each parallel step distinctly — the name is the journal key, and two concurrent steps sharing one name would share one checkpoint. Deterministic-replay rules apply unchanged: the set of steps started must be stable for the same payload.
Starting from the client
Codegen synthesises an RPC for each workflow. To start it from React, use useWorkflow(...); the call returns a run handle immediately and the durable work continues in the workflow engine:
import { useWorkflow, useWorkflowRun } from '@voltro/client'
const startImport = useWorkflow<{ uploadId: string }>('app', 'customers.import')
const run = await startImport.start({ uploadId })
const { run: liveRun } = useWorkflowRun('app', run.id)Use liveRun.status and liveRun.output to render progress/result state. Waiting for the success payload is explicit on the server with ctx.workflows.wait(...); UI code should usually subscribe to the run row instead of blocking the interaction.
Child workflows
Inside a workflow body, start child runs through ctx.workflows.child(...). The child gets its own durable run, step history, events, cancellation controls, and dashboard detail page. Voltro persists the parent execution id and parent-close policy before submitting the child start, so the relationship survives cross-runner execution in a cluster.
const child = yield* Effect.promise(() =>
ctx.workflows.child('documents.embed', { documentId }, {
parentClosePolicy: 'cancel',
}),
)
const result = yield* Effect.promise(() => ctx.workflows.wait(child))Fan out with normal Effect concurrency:
const children = yield* Effect.all(
documents.map((doc) =>
Effect.promise(() =>
ctx.workflows.child('documents.embed', { documentId: doc.id }, {
parentClosePolicy: 'abandon',
}),
),
),
{ concurrency: 8 },
)Parent-close policies:
| Policy | Behavior |
|---|---|
cancel |
Default. Interrupt open children when the parent closes. |
terminate |
Interrupt open children and record a hard parent-close action. |
abandon |
Leave the child running when the parent closes. |
The dashboard shows child runs, their parent execution id, and the selected policy. ctx.workflows.wait(child) accepts the run handle directly when the parent needs the child's success/failure snapshot.
Running on a cron — workflow({ schedule })
A workflow whose only trigger is a clock can declare the cron on itself, instead of a separate *.cron.tsx file with a workflow: target:
export default workflow({
name: 'reports.nightly',
payload: Schema.Struct({ day: Schema.String }),
idempotencyKey: (p) => `nightly:${p.day}`,
schedule: {
cron: '0 3 * * *',
timezone: 'Europe/Berlin',
payload: ({ scheduledAt }) => ({ day: scheduledAt.toISOString().slice(0, 10) }),
onOverlap: 'skip',
},
})This is sugar, not a second scheduler: at boot it lowers into a real schedule named workflow:<name> on the same coordinated cron engine every defineSchedule uses — same exactly-once claims, same run rows, same Schedules panel, same voltro schedule run / backfill verbs.
What the declaration adds is overlap vocabulary about the workflow run (Temporal Schedules' names):
onOverlap: 'skip'(default) — a firing stands down while the previous firing's run is still going.'buffer'— firings serialize behind the running one; none is lost.'cancelOther'— the new firing cancels the still-running previous run (only runs this schedule started — a manually-started run of the same workflow is never touched), then starts fresh.
The synthesized firing awaits the run to completion — that is what makes skip/buffer bind on the run's duration rather than on the milliseconds it takes to enqueue one, and it is why the firing watchdog (schedule.maxRuntime) defaults to 24 hours here instead of a plain schedule's 30 minutes. Set it above your slowest expected run. A failed run fails the firing, so a nightly job that dies every night is red in the schedule ledger, not a wall of green.
payload is a value or a function of the firing — a function receives { scheduledAt }, so a backfilled firing computes against its slot, not "now". backfill: and everything else about missed firings work exactly as on a plain schedule — see Overlap & backfill.
If the workflow also declares a deferring control (debounce, concurrency, …), a scheduled start passes the same admission gate as any other start — a deferred firing has nothing to await and hands the run to the admission queue.
Tenant and subject
Workflow starts persist the starter's trace id, source, parent execution id, parent-close policy — and their identity, never their authority — in _voltro_workflow_start_contexts. Whichever runner first executes the workflow loads that context before building the executor AppContext.
The guarantee: identity is persisted, authority is re-resolved at resume.
- Identity (type, id,
tenantId,metadata) is written and read through the same stripping function the session cookie mints through. It has to survive: the tenant scope readstenantId, the run row is attributed toid, and a plugin service resolving a per-user credential readsmetadata. A workflow started by tenant A still acts on tenant A's rows in three days' time. - Authority comes from your
auth.resolveScopeson every execution attempt, withctx.origin === 'workflow'. Wire no resolver and a resumed run has no scopes — fail-closed, and the same default a cookie-authenticated request has. - A run with no recorded caller — a bootstrap, or one whose row aged out — runs as
SYSTEM_SUBJECTand is not put through your resolver.
The column used to hold the whole Subject, scopes included. A role removed on Monday was still asserted by Thursday's resume, out of a row nothing re-validated, on a path with no request, no cookie and no expiry. Rows written by an older build are stripped on read, so a resumed run cannot re-assert authority that was persisted before this changed.
Still include tenant/user ids that the business process must enforce in payload, validate them in the first step, and scope store reads/writes deliberately. Payload data is replay-safe and makes authorization decisions auditable across retries and deploys.
Calling an HTTP API from a step
The framework's HttpClient is available inside a workflow executor — the same one handlers yield*, with the same SSRF allowlist and the same automatic traceparent propagation. yield* it in a step:
import { HttpClient, HttpClientRequest } from '@effect/platform'
export const executor = defineWorkflowExecutor(syncInvoice, (payload) =>
Effect.gen(function* () {
const remote = yield* step({ name: 'fetch-invoice' }, () =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const res = yield* client.execute(
HttpClientRequest.get(`https://billing.example.com/invoices/${payload.invoiceId}`),
)
return yield* res.json
}),
)
yield* step({ name: 'persist' }, () => database.invoices.update(payload.invoiceId, remote))
}),
)Two things follow from where it sits:
- Wrap the call in a
step. The result is then checkpointed, so a retry or a resume after a deploy replays the recorded response instead of calling the remote again. A bareyield*outside a step re-issues the request on every replay — which for a payment capture or an email send is the difference between once and several times. - It is the SSRF-guarded client. Requests to internal targets are refused by the same policy handlers get; a workflow is not a way around it. Configure the allowlist once in
app.config.tsunderhttp— dev and serve read the same key, so the policy cannot differ between them.
Reaching for fetch instead loses both: no allowlist, no trace propagation, and nothing tying the call to the step that made it.
Anti-patterns
- Using
inputinworkflow({...}). The current API ispayload. - Assuming every
yield*is a persisted step. Usestep({...})for checkpoints. - Starting workflows through
useMutation(...). UseuseWorkflow(...); workflows are durable runs, not optimistic writes. - Branching step structure on randomness or live external state. Capture the value in a step first.
- Long-lived workflows without payload versioning. A run resumed after a deploy uses the current workflow code.