Steps¶
A step is the unit of work that runs inside a workflow node.
When Spectra reaches a node during execution, it runs the step inside that node, collects the result, writes outputs into state, and then decides what should happen next.
A step can do almost anything, including:
- call an LLM
- transform data
- query an external system
- run an autonomous agent
- pause for human input
- execute a nested workflow
While nodes define the shape of the graph, steps are the thing that actually does the work.
The core idea¶
A useful way to think about Spectra:
- a workflow is a graph
- a node is a place in that graph
- a step is the behavior that runs at that place
- a result tells the engine what to do next
That separation lets you reuse the same kind of step in different workflow shapes.
The IStep contract¶
Every step implements a small interface:
public interface IStep
{
string StepType { get; }
Task<StepResult> ExecuteAsync(StepContext context);
}
| Member | Purpose |
|---|---|
StepType |
String identifier used for registration, lookup, and serialization |
ExecuteAsync |
Runs the step and returns a StepResult |
The runtime behavior comes from the context you receive and the result you return.
What a step receives: StepContext¶
When Spectra executes a step, it passes a StepContext containing everything the step needs to run: identity, resolved inputs, workflow state, services, cancellation, streaming hooks, interrupt support, and observability data.
Identity¶
| Property | Description |
|---|---|
RunId |
Unique ID for the current workflow run |
WorkflowId |
The workflow being executed |
NodeId |
The specific node running this step |
Data¶
| Property | Description |
|---|---|
Inputs |
Resolved input values for the step |
State |
The full workflow state for the current run |
By the time your step receives Inputs, template expressions have already been resolved. If a node input contains {{nodes.fetch.output}}, your step sees the actual resolved value, not the expression.
Services and execution context¶
| Property | Description |
|---|---|
Services |
Access to the DI container |
RunContext |
Caller metadata such as tenant, user, or custom run metadata |
WorkflowDefinition |
The current workflow definition when available |
Memory |
Optional long-term memory store |
Execution mode¶
| Property | Description |
|---|---|
CancellationToken |
Signals cancellation and should always be honored |
OnToken |
Callback for streaming token chunks |
IsStreaming |
Indicates whether streaming is active |
Control flow¶
| Property | Description |
|---|---|
Interrupt |
Low-level interrupt hook |
InterruptAsync(...) |
Pause execution and wait for external input |
Observability¶
| Property | Description |
|---|---|
TracingActivity |
The current tracing activity for custom tags, spans, and events |
A step does not need to know about the whole engine — it gets a focused execution context and returns a focused result.
What a step returns: StepResult¶
A step returns a StepResult to tell Spectra what happened:
public class StepResult
{
public required StepStatus Status { get; init; }
public Dictionary<string, object?> Outputs { get; init; } = [];
public string? ErrorMessage { get; init; }
public Exception? Exception { get; init; }
public TimeSpan Duration { get; init; }
public AgentHandoff? Handoff { get; init; }
}
The two most important parts are Status — what happened — and Outputs — what data the step produced. Those outputs are written into workflow state and become available to downstream nodes.
Use the factory helpers¶
Instead of constructing StepResult manually, use the built-in helpers:
StepResult.Success(new Dictionary<string, object?> { ["count"] = 42 });
StepResult.Fail("API returned 500", exception);
StepResult.NeedsContinuation("Batch 1 of 3 complete");
StepResult.Interrupted("Awaiting manager approval");
StepResult.HandoffTo(handoff, outputs);
StepResult.AwaitingInput(outputs);
What each status means¶
StepStatus controls how the engine continues.
Success and failure¶
| Status | Meaning |
|---|---|
Succeeded |
The step completed successfully and its outputs are written to state |
Failed |
The step failed and the engine records the error |
Skipped |
The step was bypassed |
Continue or repeat¶
| Status | Meaning |
|---|---|
NeedsContinuation |
The same step should run again |
Handoff |
Control is transferred to another agent |
AwaitingInput |
The workflow pauses until a new user message arrives |
Pause and resume¶
| Status | Meaning |
|---|---|
Interrupted |
Execution pauses until external input resumes the workflow |
StepResult is more than an output container — it is also a control-flow signal to the engine.
Step lifecycle¶
When the engine reaches a node, the step lifecycle looks like this:
flowchart TD
A[Resolve node inputs] --> B[Create StepContext]
B --> C[Execute step]
C --> D[Get StepResult]
D --> E[Write outputs to state]
E --> F[Emit step events]
F --> G[Evaluate outgoing edges]
- Spectra resolves node inputs from workflow state
- it builds the
StepContext - it calls
ExecuteAsync - it writes any outputs into workflow state
- it emits events
- it evaluates the next edges in the graph
That lifecycle is the same whether the step is a prompt, an agent, a custom operation, or a subgraph.
Async, cancellation, and streaming¶
All steps are asynchronous.
Cancellation¶
Always honor the cancellation token:
public async Task<StepResult> ExecuteAsync(StepContext context)
{
var response = await _httpClient.GetAsync(url, context.CancellationToken);
context.CancellationToken.ThrowIfCancellationRequested();
var result = await ProcessAsync(response, context.CancellationToken);
return StepResult.Success(new()
{
["result"] = result
});
}
This is especially important for long-running or externally dependent work.
Streaming¶
Steps that produce incremental text can stream token chunks back to the caller:
if (context.IsStreaming)
{
foreach (var chunk in chunks)
{
await context.OnToken!(chunk, context.CancellationToken);
}
}
Built-in step types such as prompt, agent, and session workflows support streaming directly.
Interrupts and human input¶
A step can pause execution and wait for external input:
var response = await context.InterruptAsync("review-needed", b => b
.WithTitle("Review Required")
.WithPayload(new { document = "draft-v2.md" }));
var approved = response.Payload["approved"];
This is useful for workflows that need manager approval, manual review, human validation, or external confirmation before continuing. When a step interrupts, the workflow can be checkpointed and resumed later.
See Interrupts for the full model.
Built-in step types¶
Spectra includes several built-in step types.
LLM and agent steps¶
| Step | StepType | Purpose | Guide |
|---|---|---|---|
PromptStep |
"prompt" |
Single LLM completion with prompt resolution and optional streaming | Prompt Steps |
StructuredOutputStep |
"structured_output" |
LLM response constrained to structured JSON output | Prompt Steps |
AgentStep |
"agent" |
Autonomous tool-using agent loop | Agent Step |
Orchestration steps¶
| Step | StepType | Purpose | Guide |
|---|---|---|---|
SubgraphStep |
"subgraph" |
Run a nested workflow inside a parent workflow | Subgraphs |
Memory steps¶
| Step | StepType | Purpose | Guide |
|---|---|---|---|
MemoryStoreStep |
"memory.store" |
Persist data into long-term memory | Memory & Threading |
MemoryRecallStep |
"memory.recall" |
Read data back from long-term memory | Memory & Threading |
Session-based interaction¶
SessionStep represents a different interaction style — a persistent conversational boundary rather than a one-shot unit of work. See Sessions for that model.
When to create a custom step¶
Create your own step when you want a reusable unit of work that does not fit the built-in types. Common reasons include:
- custom API integrations
- business-specific transformation logic
- special validation or routing behavior
- proprietary tools or services
- domain-specific execution patterns
A custom step is often the best way to keep workflow graphs clean while moving implementation detail into normal .NET code.
Practical guidance¶
- put graph structure in the workflow
- put reusable work in steps
- return clear outputs
- keep control-flow intent explicit through
StepResult
A good step should be easy to understand in isolation and easy to compose into larger workflows.
Where to go next¶
- Use built-in LLM steps — see Prompt Steps
- Build autonomous agents — see Agent Step
- Create your own reusable step type — see Custom Step Guide
- Work with conversation-style execution — see Sessions
- Compose workflows from smaller workflows — see Subgraphs
It also helps to read State and Parallel Execution next, since steps become much easier to use once you understand how data flow and scheduling work together.