Workflows & Nodes
A Workflow in Invariant is an immutable compiled graph defining the allowed execution paths of a business process.
"Each execution node has one responsibility: compute, reason, perform an external effect, or wait."
The DSL Taxonomy
The Invariant Workflow DSL organizes execution into three distinct categories:
| Category | Primitive | Responsibility |
|---|---|---|
| Execution | .step() | Deterministic in-process computation (no external I/O) |
.reason() | Bounded model reasoning (probabilistic, retryable) | |
.capability() | External side effect (Outbox-backed, retryable) | |
.wait() | Durable suspension (no active worker process) | |
| Control Flow | .branch() | Deterministic path selection |
.repeat() | Durable loop iteration | |
.child() | Separate child workflow execution | |
| Composition | app.fragment() | Reusable graph structure |
The Primitive Selection Matrix
When choosing a primitive, ask:
| Question / Requirement | Primitive |
|---|---|
| "What does this customer message mean?" | .reason() |
| "Is this order within the 30-day refund window?" | .step() |
| "What does Stripe say about this charge?" | .capability() |
| "Which execution path should we take?" | .branch() |
| "We need the user to choose an appointment slot." | .wait() |
"Choose primitives by responsibility, not by implementation convenience."
One Complete Example
Here is a complete workflow combining reasoning, deterministic evaluation, branching, capabilities, and waiting:
import { z } from "zod";
import { app } from "./runtime";
export const supportWorkflow = app
.workflow("customer-support", {
inputSchema: z.object({
customerId: z.string(),
message: z.string(),
}),
})
// 1. External Data Load (Capability)
.capability("load-customer", loadCustomer)
// 2. Semantic Request Classification (Reasoning)
.reason("classify-request", {
prompt: ({ input }) => `Classify request: "${input.message}"`,
schema: z.object({
category: z.enum(["REFUND", "TECHNICAL", "ACCOUNT", "OTHER"]),
urgency: z.enum(["LOW", "MEDIUM", "HIGH"]),
}),
})
// 3. Deterministic Policy Check (Step)
.step("check-policy", ({ state }) => ({
eligible:
state.customer?.active &&
state.category === "REFUND" &&
Boolean(state.customer?.refundWindowOpen),
}))
// 4. Control-Flow Branching
.branch("route", ({ state }) => (state.eligible ? "AUTO_REFUND" : "MANUAL_REVIEW"), {
AUTO_REFUND: app.fragment("auto-refund")
.capability("issue-refund", issueRefund)
.step("build-receipt", buildReceipt)
.capability("send-receipt", sendReceipt),
MANUAL_REVIEW: app.fragment("manual-review")
.wait("human-approval", { schema: z.object({ approved: z.boolean() }) }),
}); Workflow Graph
│
capability("load-customer")
│
reason("classify-request")
(probabilistic interpretation)
│
step("check-policy")
(deterministic policy)
│
branch("route")
(choose a path)
┌─────┴─────┐
▼ ▼
AUTO_REFUND MANUAL_REVIEW
│ │
capability() wait()
(side-effect) (suspension)Execution Primitives
1. Steps (.step)
Defines a deterministic in-process calculation.
workflow.step("clean-input", ({ input }) => ({
cleanQuery: input.query.trim().toLowerCase(),
}));Steps are for pure, deterministic in-process computations and must not perform external network I/O or database queries.
Because steps perform deterministic in-process computation without external side-effects, the runtime does not apply retry semantics to them in the same way as model or capability boundaries. For internal evaluation errors (such as JSON parsing), steps accept an optional fallback node:
workflow.step("parse-json-payload", {
fallback: "recovery-step",
handler: ({ input }) => ({
data: JSON.parse(input.raw),
}),
});2. Reasoning (.reason)
Delegates a bounded semantic task to an LLM inside an explicit context and output boundary.
workflow.reason("classify-request", {
instruction: "Classify the customer's support request.",
context: ({ input }) => ({ message: input.message }),
schema: z.object({
category: z.enum(["REFUND", "DELIVERY", "ACCOUNT", "OTHER"]),
urgency: z.enum(["LOW", "MEDIUM", "HIGH"]),
}),
retry: {
maxAttempts: 3,
backoffMs: 1000,
},
fallback: "fallback-classification",
});Use
.reason()when the answer requires semantic judgment—not where authoritative application logic can determine the answer.
3. Capabilities (.capability)
Use .capability() whenever the workflow needs to interact with something outside its deterministic execution state: an API, database, queue, email provider, filesystem, or external service.
workflow.capability("issue-refund", {
idempotencyKey: ({ execution }) => `refund:${execution.id}`,
retry: {
maxAttempts: 5,
backoffMs: 2000,
},
handler: async ({ state, execution }) => {
return await stripe.refunds.create(
{ charge: state.chargeId },
{ idempotencyKey: `refund:${execution.id}` }
);
},
});Invariant persists capability intent durably; the runtime uses its Outbox mechanism to execute and retry the effect safely.
4. Waits (.wait)
Suspends workflow execution durably until a matching external event or user input arrives.
workflow.wait("select-appointment-slot", {
schema: z.object({
slotId: z.string(),
}),
options: ({ state }) =>
state.availableSlots.map((slot) => ({
label: `${slot.time} with ${slot.staffName}`,
value: slot.id,
})),
timeout: "24h",
});
.wait()does not keep a worker alive. The execution enters a durable suspended state and can resume seconds, hours, or days later.
Control-Flow & Composition Primitives
1. Branching (.branch)
Deterministically route execution between graph branches:
workflow.branch(
"refund-decision",
({ state }) => (state.approved ? "approved" : "denied"),
{
approved: approvedFragment,
denied: deniedFragment,
}
);2. Fragments (app.fragment())
Fragments are reusable graph definitions composed directly into parent workflows or branch handlers:
const approvedFragment = app.fragment("approved-refund")
.capability("issue-refund", issueRefund)
.step("build-receipt", buildReceipt)
.capability("send-receipt", sendReceipt);Fragments compose graphs. Branches choose paths. Child Workflows create separate executions.
3. Loops (.repeat)
Durable iteration for iterative workflows, computer-use agents, polling-style business processes, or bounded model/action loops:
Retry Policy vs. Repeat Loop: Use node retry policies (
retry: { maxAttempts: 3 }) for transient infrastructure/API failures. Use.repeat()when iteration is part of the core workflow semantics.
const computerLoop = app.fragment("computer-loop-fragment")
.capability("capture-screen", captureScreen)
.reason("decide-next-action", {
prompt: ({ state }) => `Screen state: ${JSON.stringify(state.screen)}`,
schema: ComputerActionSchema,
})
.branch("execute-action", ({ state }) => state.action, {
CLICK: app.fragment("click").capability("click-mouse", clickMouse),
TYPE: app.fragment("type").capability("type-keyboard", typeKeyboard),
DONE: app.fragment("done").break(),
});
export const computerUseWorkflow = app.workflow("computer-use")
.repeat("ui-automation-loop", {
maxIterations: 100,
do: computerLoop,
});4. Child Workflows (.child)
Creates a separate durable execution with its own runId, event log, and cancellation lifecycle:
workflow.child("fraud-check-child", fraudReviewWorkflow, {
input: ({ input, state }) => ({ userId: input.userId, amount: state.refundAmount }),
});Input vs. State Accumulation
Invariant separates immutable execution parameters (input) from accumulated execution state (state):
input: Immutable initial workflow execution parameters (TInput = z.infer<typeof inputSchema>).state: Accumulated execution state derived from node outputs (TState). Initially{}.
const workflow = app.workflow("refund", {
inputSchema: z.object({ userId: z.string() }),
})
.capability("load-customer", loadCustomer)
.step("check-eligibility", ({ state }) => ({
eligible: Boolean(state.customer?.active && state.customer?.refundWindowOpen),
}))
.capability("issue-refund", {
handler: async ({ state }) => {
return await stripe.refunds.create({ charge: state.customer.chargeId });
},
});Workflows Can Run Independently
A Workflow does not require an Agent. It can be started directly by your application, a cron scheduler, a webhook, or an Agent:
REST API ────────┐
Cron Job ────────┤
Webhook ─────────┼──► Workflow ──► Execution
Agent ───────────┘An Agent is simply one possible driver. A workflow containing .reason() is still a Workflow—it does not become an Agent.
For execution paths and driver choices, see Mental Model.
Go Deeper
- Mental Model — Compare programmatic workflows vs. agent-driven execution.
- Durability Guarantees — Understand atomic persistence and outbox semantics.
- Agents & Actions — Learn how an
app.agent()evaluates intent to drive registered workflows.