Skip to main content

Flows and Scripts

Stem supports two workflow models. Both are durable. They differ in where the execution plan lives.

The distinction​

ModelSource of truthBest for
FlowDeclared stepsExplicit orchestration, clearer admin views, fixed step order
WorkflowScriptThe Dart code in run(...)Branching, loops, and function-style workflow authoring

The confusing part is that both models expose step-like metadata. The difference is that for script workflows those are checkpoints, not the plan itself.

  • Flow: the runtime advances through the declared step list.
  • WorkflowScript: the runtime re-enters run(...) and durable boundaries are created when script.step(...) executes.
  • Script checkpoints exist for replay boundaries, manifests, dashboards, and tooling.

Neither model is deprecated. HostedWorkflow is a separate, function-first facade that lowers into the script runtime; use it when that is the authoring surface you want. Use the lower-level models here when you need direct control over definitions, contexts, or registration.

Flow example​

lib/workflows/approvals_flow.dart
class ApprovalsFlow {
static final flow = Flow<String>(
name: 'approvals.flow',
build: (flow) {
flow.step('draft', (ctx) async {
final draft = ctx.requiredParamJson<ApprovalDraft>(
'draft',
decode: ApprovalDraft.fromJson,
);
return draft.documentId;
});

flow.step('manager-review', (ctx) async {
final resume = ctx.waitForEventValueJson<ApprovalDecision>(
'approvals.manager',
decode: ApprovalDecision.fromJson,
);
if (resume == null) {
return null;
}
return resume.approvedBy;
});

flow.step('finalize', (ctx) async {
final approvedBy = ctx.previousValue<String>();
return 'approved-by:$approvedBy';
});
},
);

static final ref = flow.refJson<ApprovalDraft>();
}

Future<void> registerFlow(StemWorkflowApp workflowApp) async {
workflowApp.registerFlows([ApprovalsFlow.flow]);
}

Future<void> registerWorkflowDefinition(StemWorkflowApp workflowApp) async {
workflowApp.registerWorkflows([ApprovalsFlow.flow.definition]);
}

The example above is an extracted, source-tested region. Its ApprovalDraft and ApprovalDecision types, imports, and app setup live in the linked docs_snippets file; copy those definitions too when making a standalone example.

Manual flows can also derive a typed workflow ref from the definition:

final approvalsRef = approvalsFlow.ref<Map<String, Object?>>(
encodeParams: (draft) => <String, Object?>{'draft': draft},
);

When a flow has no start params, start directly from the flow itself with flow.start(...) or flow.startAndWait(...). Keep flow.ref0().asRef.buildStart(params: ()) for the rarer cases where you want to assemble or adjust overrides before dispatch. Use ref0() only when another API specifically needs a NoArgsWorkflowRef.

Use Flow when:

  • the sequence of durable actions should be obvious from the definition
  • each step maps cleanly to one business stage
  • your operators care about a stable, declared step list

Script example​

lib/workflows/retry_script.dart
final retryScript = WorkflowScript(
name: 'billing.retry-script',
run: (script) async {
final chargeId = await script.step<String>('charge', (ctx) async {
final resume = ctx.waitForEventValueJson<ChargePrepared>(
'billing.charge.prepared',
decode: ChargePrepared.fromJson,
);
if (resume == null) {
return 'pending';
}
return resume.chargeId;
});

final receipt = await script.step<String>('confirm', (ctx) async {
ctx.idempotencyKey('confirm-$chargeId');
return 'receipt-$chargeId';
});

return receipt;
},
);

final retryDefinition = retryScript.definition;

Future<void> registerScript(StemWorkflowApp workflowApp) async {
workflowApp.registerScripts([retryScript]);
}

Manual scripts support the same pattern. script.step(...) is the durable boundary; ordinary code between checkpoints runs again during replay, so keep replay-sensitive side effects inside a checkpoint and make them idempotent.

final retryRef = retryScript.ref<Map<String, Object?>>(
encodeParams: (params) => params,
);

When a script has no start params, start directly from the script itself with retryScript.start(...) or retryScript.startAndWait(...). Keep retryScript.ref0().asRef.buildStart(params: ()) for the rarer cases where you want to assemble or adjust overrides before dispatch. Use ref0() only when another API specifically needs a NoArgsWorkflowRef.

Use WorkflowScript when:

  • you want normal Dart control flow to define the run
  • the workflow has branching or repeated patterns
  • you want a more function-like authoring model

Contexts in each model​

  • flow steps receive FlowContext
  • script runs may receive WorkflowScriptContext
  • script checkpoints may receive WorkflowScriptStepContext

The full injection and parameter rules are documented in Context and Serialization.