Introduction
AI agents are often described as loops: observe, reason, act, repeat. That is useful for explaining agent behavior, but it is not enough for a production workflow.
A real system also needs to answer questions such as: Is the agent planning or executing? Has validation passed? Is human approval required? Did execution fail? Can the operation be retried safely? Can the workflow resume after a process restart?
A state machine gives those questions an explicit model. Instead of inferring lifecycle from scattered Boolean fields or log messages, the application records a current state and allows only defined transitions.
This article builds a small framework-neutral example in C# and shows how to separate AI reasoning from workflow authority.
Environment and prerequisites
Language: C# 12
Runtime target: .NET 8
Framework: None required; the example uses core C# only
Code status: Illustrative code — technical validation required.
Prerequisites: basic familiarity with C# enums, classes, switch expressions, application services, and persistent storage.
By the end, you should be able to model an agent lifecycle, reject illegal transitions, represent approval explicitly, handle retries and failures, and keep the model from deciding its own execution authority.
Define lifecycle state explicitly
Start by naming the states that matter to the workflow.
The following enum describes a simple lifecycle from creation through planning, validation, approval, execution, and completion. It also includes failure and cancellation paths.
public enum AgentWorkflowState
{
// Normal lifecycle states.
Created,
Planning,
Validated,
AwaitingApproval,
Approved,
Executing,
RetryPending,
Completed,
// Exception and terminal states.
ValidationFailed,
ExecutionFailed,
Rejected,
Cancelled
}
The enum answers one question: Which states can this workflow occupy?
It does not yet answer which transitions are legal. Without transition rules, application code could accidentally jump directly from Created to Completed or move a rejected workflow back into execution.
Encode legal transitions
A small transition policy can make the lifecycle explicit.
This example uses a C# switch expression over a tuple containing the current and requested states.
public static class WorkflowTransitions
{
public static bool CanTransition(
AgentWorkflowState current,
AgentWorkflowState next)
{
return (current, next) switch
{
(AgentWorkflowState.Created,
AgentWorkflowState.Planning) => true,
(AgentWorkflowState.Planning,
AgentWorkflowState.Validated) => true,
(AgentWorkflowState.Planning,
AgentWorkflowState.ValidationFailed) => true,
(AgentWorkflowState.Validated,
AgentWorkflowState.AwaitingApproval) => true,
(AgentWorkflowState.AwaitingApproval,
AgentWorkflowState.Approved) => true,
(AgentWorkflowState.AwaitingApproval,
AgentWorkflowState.Rejected) => true,
(AgentWorkflowState.Approved,
AgentWorkflowState.Executing) => true,
(AgentWorkflowState.Executing,
AgentWorkflowState.Completed) => true,
(AgentWorkflowState.Executing,
AgentWorkflowState.ExecutionFailed) => true,
(AgentWorkflowState.ExecutionFailed,
AgentWorkflowState.RetryPending) => true,
(AgentWorkflowState.RetryPending,
AgentWorkflowState.Executing) => true,
// Deny every transition that is not explicitly listed.
_ => false
};
}
}
The final fallback is important. New or unexpected state pairs are denied unless they are added deliberately.
That produces a useful default: a transition is illegal unless the application explicitly allows it.
Keep transitions behind the workflow object
A transition policy is only useful if callers cannot bypass it.
The workflow object can expose state as read-only and require changes to go through one method.
using System;
public sealed class AgentWorkflow
{
public Guid Id { get; } = Guid.NewGuid();
// State changes only through TransitionTo.
public AgentWorkflowState State { get; private set; }
= AgentWorkflowState.Created;
public int RetryCount { get; private set; }
public void TransitionTo(AgentWorkflowState next)
{
if (!WorkflowTransitions.CanTransition(State, next))
{
throw new InvalidOperationException(
$"Transition from {State} to {next} is not allowed.");
}
State = next;
}
public void RegisterRetry()
{
RetryCount++;
}
}This keeps the state mutation in one place. A larger implementation would normally record more context, such as the previous state, timestamp, actor identity, reason, error details, and an operation ID.
Join the conversation! Your thoughts help the community grow.