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 with a typed failure event and optional fallback edge | |
.capability() | External side effect with durable intent and an optional fallback edge | |
.wait() | Durable suspension (no active worker process) | |
| Control Flow | .branch() | Deterministic path selection |
.repeat() | Durable loop iteration | |
.child() | Reserved composition operator; rejected by the current Beta preflight | |
| 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 Graph Excerpt
This excerpt shows how reasoning, deterministic evaluation, branching, capabilities, and waiting compose. It assumes application-owned app, customer, refund, and receipt functions; use the Workflow Patterns Catalog for self-contained fixtures:
ts
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", {
instruction: "Classify the customer request in execution context.",
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() }) }),
});text
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.
ts
workflow.step("clean-input", ({ input }) => ({
cleanQuery: input.query.trim().toLowerCase(), // <-- Shallow-merged into state.cleanQuery
}));Steps are for pure, deterministic in-process computations and must not perform external network I/O or database queries.
State Merge & Type Flow
When a .step() returns an object (e.g. { total: number }), the Invariant engine shallow-merges that object into the execution state. TypeScript automatically infers the new accumulated state type for all subsequent nodes in the chain.
Because steps perform deterministic in-process computation without external side-effects, the runtime does not apply network retry semantics to them. Handle expected parsing or policy outcomes explicitly in the returned state and route them with .branch().
If a step handler throws, is missing at execution time, or returns a non-object value, the Host emits STEP_FAILED; it never substitutes {} or emits STEP_COMPLETED. Without an authored fallback in lower-level IR, the Kernel derives WORKFLOW_FAILED, commits both facts atomically, and does not run downstream nodes. A failed step emits the diagnostic trace node.failed, not node.completed.
2. Reasoning (.reason)
Delegates a bounded semantic task to an LLM inside an explicit context and output boundary.
"
.reason()is a bounded probabilistic computation inside a durable execution graph."
ts
// Current Beta host: static instruction + accumulated execution context
workflow.reason("classify-request", {
instruction: "Classify the customer's support request in execution context.",
schema: z.object({
category: z.enum(["REFUND", "DELIVERY", "ACCOUNT", "OTHER"]),
urgency: z.enum(["LOW", "MEDIUM", "HIGH"]),
}),
fallback: "fallback-classification",
});Core Principles of .reason()
- Produces a semantic proposal: The model output is validated against the authoritative authored schema before being committed to durable state. Plain objects use the fail-closed Invariant Runtime Schema v1 subset; Zod and custom runtime schemas use their own validators.
- Current host context: The Beta Session host sends the accumulated execution context produced by the kernel command.
- Reserved selectors: Dynamic
prompt,context, andprojectionauthoring fields are not evaluated by the current Beta host. Do not treat them as an active data-minimization boundary.
Media inputs in the current Beta
.reason() sends accumulated context as JSON text. Image URLs, base64 strings, audio references, and document IDs are not converted into provider-native content parts. Store media in an application-controlled object store and process it through a bounded .capability() or application-owned custom adapter if needed. First-party portable image input is a post-v1/PR candidate, not a requirement of the v1 compatibility surface. See the Model Adapter input matrix.
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.
Explicit Return Contracts
Capabilities explicitly return objects with the keys you want to expose in state:
ts
// 1. Function shorthand: returns { order } to state
async function loadOrder({ input }: { input: { orderId: string } }) {
const order = await db.orders.find(input.orderId);
return { order };
}
workflow.capability("load-order", loadOrder);Idempotency and Fallbacks
Declare an Invariant command identity and fallback routing. If the provider supports deduplication, include a separate stable application request ID in the workflow input and reuse it for the same logical operation:
ts
workflow.capability("issue-refund", {
idempotencyKey: "refund:{{runId}}",
fallback: "manual-review",
handler: async ({ input, state }) => {
const receipt = await stripe.refunds.create(
{ charge: state.chargeId },
{ idempotencyKey: input.refundRequestId }
);
return { receipt }; // <-- Now accessible in state.receipt
},
});Invariant persists capability command intent durably before executing the handler; the initiating Session drains the effect in-process and commits completion without re-invoking prior steps or LLM calls. Cross-process redispatch is not a public Beta API. See Effect Identity.
4. Waits (.wait)
Suspends workflow execution durably until a matching external event or user input arrives.
"A wait defines what execution is waiting for. Presentation describes that boundary. Projections decide how a consumer experiences it."
ts
workflow.wait<{ slotId: string }>("select-appointment-slot", {
schema: z.object({
slotId: z.string(),
}),
presentation: ({ state }) => ({
kind: "slot_picker",
title: "Select an appointment slot",
slots: state.availableSlots.map((slot) => ({
label: `${slot.time} with ${slot.staffName}`,
slotId: slot.id,
})),
}),
});
.wait()is completely headless. The runtime does not care if the consumer is React, Voice, MCP, or a 3D scene. The workflow defines the re-entry contract (schema) and boundary metadata (presentation), while channel-specificapp.projection()instances decide how that boundary is experienced.
.wait()does not keep a worker alive. The execution enterswaiting_input. With a durable store,loadOrCreateSession()orrestoreSession()can reconstruct that committed boundary in a new process when the same workflow graph is registered, then accept the next validated input under the stored revision and ownership contract. This request-driven wait reattachment is not background work recovery: it does not claim or redispatch an interrupted capability command. See the Recovery Contract Matrix.
Validation Boundary & Rejection Semantics ("Unvalidated input is not execution history")
When an external payload arrives for a suspended .wait() node:
- Rejection is NOT Failure: If submitted data does not satisfy the declared
schema(e.g. missing required fields, invalid enum values, or malformed types),session.submitInput()rejects withWaitInputValidationError. The workflow does not fail and is not cancelled. Invariant does not choose an HTTP status; transport mapping belongs to the application. - State Invariance: The accumulated workflow state (
state.data), optimistic concurrency revision (revision), and event history remain 100% intact. Unvalidated input never becomes execution history. - Resolving the Wait Node: The execution remains safely at
currentNodeId = <wait-node>until:- Valid Input Received: The client/UI submits a valid payload conforming to schema $\rightarrow$ session resumes and advances to the next node.
- Application-driven timeout: The Beta host does not schedule timers automatically. Application scheduling code may cancel or submit a typed escalation input.
- Explicit cancellation: Application code calls
session.cancelWorkflow(reason).
Control-Flow & Composition Primitives
1. Branching (.branch)
Deterministically route execution between graph branches based on initial parameters (input) or accumulated state (state):
ts
// 1. Parameter or state-based routing
workflow.branch(
"location-check",
({ input }) => (input.locationId ? "PROVIDED" : "PROMPT"),
{
PROVIDED: app.fragment("use-loc").step("set-loc", ({ input }) => ({ locationId: input.locationId })),
PROMPT: app.fragment("prompt-loc").wait("select-loc-ui", { schema: SelectLocationSchema }),
}
);
// 2. Type-safe branch convergence
workflow.branch<{ serviceSelection: ServiceSelection }>(
"service-selection-path",
({ state }) => (state.categoryName === "Grooming" ? "GUIDED_GROOMING" : "STANDARD_SERVICE"),
{
GUIDED_GROOMING: haircutGuidedFragment, // outputs { serviceSelection: ServiceSelection }
STANDARD_SERVICE: standardServiceFragment, // outputs { serviceSelection: ServiceSelection }
}
);The selector is the only branch authority. It must return one declared string key. Invariant does not scan state values and does not interpret magic fields such as branchKey, approved, status, or action. A missing selector or missing target is rejected by preflight before WORKFLOW_STARTED; a selector that throws or returns an unmatched key during execution produces a durable WORKFLOW_FAILED fact.
An authored empty fragment is compiled as an explicit terminal branch and completes that graph path. It is not a pass-through alias. To continue to the node after .branch(), author at least one real node in that branch—for example, a deterministic no-op .step("continue", () => ({})) when no state change is required.
Branch Output Convergence
When distinct branches implement alternative paths towards a shared business contract (such as a guided questionnaire vs. a direct catalog picker), Invariant allows declaring the converged output type. The downstream workflow state immediately inherits the converged properties with complete TypeScript autocompletion and strict type checking.
2. Fragments (app.fragment())
Fragments are reusable graph definitions composed directly into parent workflows or branch handlers. Fragments support all workflow primitives—including .step(), .capability(), .wait(), .reason(), .repeat(), and nested .branch() decision trees:
ts
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:
Fallback vs. Repeat Loop: Use a node
fallbackedge for a typed reasoning or capability failure. Use.repeat()only when iteration is part of the business workflow itself. Automatic retry policy configuration is not part of the current Beta API.
ts
const computerLoop = app.fragment("computer-loop-fragment")
.capability("capture-screen", captureScreen)
.reason("decide-next-action", {
instruction: "Choose the next bounded computer action from execution context.",
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")
.step("initialize-loop", () => ({ continueAutomation: true }))
.repeat("ui-automation-loop", {
while: ({ state }) => state.continueAutomation,
maxIterations: 100,
do: computerLoop.step("update-loop-condition", ({ state }) => ({
continueAutomation: state.action !== "DONE",
})),
});maxIterations is a required positive integer and remains the hard safety cap even when while is supplied. On every entry, the runtime checks the cap and then evaluates while against committed application state before entering the body. false permits zero iterations and exits to the next node; true enters the body and durably advances the repeat counter. A thrown or non-boolean condition fails the workflow explicitly instead of being treated as false or ignored. Repeat counters live in runtime-owned loopState, not application state.
4. Child Workflows (.child)
.child() is reserved for a future composition profile. The builder can describe this node, but current Beta preflight rejects it with UnsupportedNodeError; do not use it in runnable workflows.
ts
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 validated input plus node outputs (TState).
ts
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 });
},
});Workflow Lifecycle & Compensation (lifecycle.cancel)
"Cancellation does not erase external reality. Compensation must be modeled explicitly."
- Runtime can stop future execution (Freezes and drops scheduled work in the forward graph).
- Runtime preserves what already happened (Event history and historical facts remain completely immutable).
- Application defines how external effects are compensated (Executes a durable compensation fragment).
When a workflow is cancelled with session.cancelWorkflow() or the Agent cancel_workflow action, external effects that already occurred cannot simply disappear.
Invariant models compensation as a first-class, durable WorkflowFragment rather than an arbitrary ephemeral JS callback:
ts
const cancelBooking = app.fragment("cancel-booking")
.capability("release-slot", releaseSlot)
.capability("void-payment-authorization", voidPaymentAuth)
.capability("notify-customer", notifyCancellation);
export const bookingWorkflow = app.workflow("booking", {
description: "Handles salon appointment booking",
lifecycle: {
cancel: cancelBooking,
},
})
.capability("reserve-slot", reserveSlot)
.wait("await-user-confirmation")
.capability("charge-deposit", chargeDeposit);Compensation vs. Rollback
"Compensation creates new facts; it does not erase old ones."
- Database Transaction: Rolls back uncommitted rows.
- External Side-Effect: Real-world committed fact. You cannot "un-send" an email or "un-reserve" a slot by rewriting history. You emit a new compensation fact:
text
CAPABILITY_COMPLETED (slotId = "slot_99")
WORKFLOW_CANCEL_REQUESTED (reason = "Customer changed plans", interruptedNode = "await-user-confirmation")
WORKFLOW_CANCELLATION_STARTED (entry = "__lifecycle.cancel.release-slot")
CAPABILITY_COMPLETED (releasedSlot = "slot_99")
CAPABILITY_COMPLETED (voidedAuth = "auth_441")
CAPABILITY_COMPLETED (notified = true)
WORKFLOW_CANCELLEDThe 4 Status Guarantees
text
RUNNING / WAITING
│
│ WORKFLOW_CANCEL_REQUESTED
▼
┌────┴────┐
│ │
no hook hook
│ ↓
│ CANCELLING
│ ↙ ↘
▼ ✓ ✕
CANCELLED CANCELLATION_FAILEDCANCELLING: The runtime has frozen the main workflow and is executing the compensation fragment using the same stored-command and idempotency boundaries as the main graph. DuringCANCELLING, all agent input actions are rejected.CANCELLED:CANCELLEDmeans its configured compensation lifecycle completed successfully.CANCELLATION_FAILED: If a capability in the compensation fragment fails without a successful fallback, execution is markedcancellation_failed(it is never falsely marked asCANCELLED).- Idempotency: Repeated calls to
cancel()whileCANCELLINGor in terminal states are safe, idempotent no-ops.
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:
text
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.