AI Agent State Machines: Build Workflows That Cannot Lose the Plot
Agents fail quietly when memory becomes mush. This guide shows how to lock LLM workflows into explicit states, transitions, retries, and clean exits.
Most AI agents do not fail with a dramatic explosion. They fail by drifting one tiny decision at a time until nobody knows what the workflow is doing anymore.
That is why ai agent state machines production work matters. A prompt can suggest a process. A state machine enforces one.
If your agent collects requirements, calls tools, waits for approval, edits records, sends emails, or touches money, you need more than “the model will figure it out.” That is not architecture. That is a group chat with root access.
A production agent needs a map: where it is, what it knows, what can happen next, what is forbidden, and how it exits.
That map is a state machine.
What You Are Building
In this tutorial, you will design a production-ready agent workflow using a state machine pattern. The example is a support triage agent, but the same structure works for sales qualification, research pipelines, document review, onboarding flows, coding assistants, internal ops bots, and any other AI workflow that needs to stop wandering.
The agent will move through explicit states:
intakeclassifyneeds_more_inforetrieve_contextdraft_responsehuman_reviewsend_responseclosedfailed
Each state has a job. Each transition has a rule. The model can still reason, write, classify, and summarize, but it does not get to invent the process on the fly.
That is the whole point.
Prerequisites
You do not need to be a hardcore backend engineer, but you should understand the basics of APIs, JSON, and automation workflows.
You will need:
- A JavaScript or TypeScript runtime such as Node.js
- An LLM API of your choice
- Basic comfort reading code
- A workflow that has more than one step
- At least one place where the agent can get stuck, retry, or require approval
Optional but useful:
- XState if you want a formal state machine library
- LangGraph if you are building graph-based agent workflows
- AWS Step Functions, Temporal, or another durable workflow engine if the process must survive crashes and restarts
For this article, the examples use TypeScript-style pseudocode because it is readable even if you are not shipping Node in production.
Why Agents Lose The Plot
LLMs are good at flexible reasoning. They are bad at being a reliable workflow engine.
A model can remember that it already asked the user for missing billing details. It can also forget. It can decide that “almost enough information” is good enough. It can call a tool twice. It can skip approval because the previous message sounded confident. It can hallucinate the next step because you buried the real process in paragraph seven of your system prompt.
That is not a model failure. That is a design failure.
Production workflows need hard boundaries:
- What state are we in?
- What data is required to leave this state?
- What events are allowed here?
- What happens when a tool fails?
- What needs human approval?
- What counts as done?
- What gets logged?
A state machine makes those boundaries explicit.
State Machine Basics Without The Academic Fog
A state machine is a model of a process with a finite set of states and rules for moving between them.
You only need five concepts.
State
The current stage of the workflow.
Example:
state: "draft_response"
This tells your system what the agent is currently allowed to do.
Context
The data the workflow carries forward.
Example:
context: {
ticketId: "SUP-1042",
customerMessage: "I was charged twice.",
category: "billing",
priority: "high",
draft: null,
retryCount: 0
}
The state says where you are. The context says what you know.
Event
Something that happens.
Example:
{ type: "CLASSIFIED", category: "billing", priority: "high" }
Events should be named like facts, not wishes. CLASSIFIED is better than CONTINUE_MAYBE.
Transition
A rule that says which state comes next.
Example:
on: {
CLASSIFIED: "retrieve_context",
MISSING_INFO: "needs_more_info"
}
The model can provide the classification. The machine decides the route.
Action
Work performed during a transition or inside a state.
Example:
actions: ["saveClassification", "loadCustomerHistory"]
Actions call tools, write logs, update databases, send notifications, or ask the LLM to generate structured output.
Step 1: Draw The Workflow Before You Code
Start with the actual business process. Not the dream version. The messy version.
For a support triage agent, the flow might look like this:
intake
-> classify
-> needs_more_info
-> classify
-> retrieve_context
-> draft_response
-> human_review
-> send_response
-> closed
Now add the ugly paths:
classify
-> failed
retrieve_context
-> failed
draft_response
-> human_review
-> draft_response
send_response
-> failed
Expected result: you should have a workflow diagram where every state has a purpose and every exit path is named. If a box says “agent decides,” rewrite it. That is where bugs breed.
Step 2: Define Your States
Here is a simple state list:
type TicketState =
| "intake"
| "classify"
| "needs_more_info"
| "retrieve_context"
| "draft_response"
| "human_review"
| "send_response"
| "closed"
| "failed";
Now define your workflow context:
type TicketContext = {
ticketId: string;
customerMessage: string;
category?: "billing" | "technical" | "account" | "other";
priority?: "low" | "medium" | "high";
missingFields?: string[];
customerHistory?: string;
draftResponse?: string;
reviewerNotes?: string;
retryCount: number;
error?: string;
};
Expected result: you now have a finite set of states and a known payload. The workflow is no longer an open-ended text blob.
Step 3: Make Transitions Explicit
Here is a plain object version before we bring in libraries:
const transitions = {
intake: {
RECEIVED: "classify"
},
classify: {
CLASSIFIED: "retrieve_context",
MISSING_INFO: "needs_more_info",
FAILED: "failed"
},
needs_more_info: {
USER_REPLIED: "classify",
TIMEOUT: "failed"
},
retrieve_context: {
CONTEXT_FOUND: "draft_response",
CONTEXT_UNAVAILABLE: "draft_response",
FAILED: "failed"
},
draft_response: {
DRAFT_READY: "human_review",
FAILED: "failed"
},
human_review: {
APPROVED: "send_response",
CHANGES_REQUESTED: "draft_response",
REJECTED: "failed"
},
send_response: {
SENT: "closed",
FAILED: "failed"
},
closed: {},
failed: {}
} as const;
This looks boring. Good. Production workflows should be boring at the control layer.
Expected result: if the workflow is in human_review, it cannot suddenly send a message because the model got ambitious. It needs an APPROVED event first.
Step 4: Put The LLM Inside States, Not Above Them
The common mistake is making the LLM the boss:
User message -> LLM decides everything -> tools maybe happen -> hope
Do this instead:
State machine decides current step -> LLM performs narrow task -> machine validates output -> transition
For classification, ask the model for structured output:
async function classifyTicket(context: TicketContext) {
const result = await callLLM({
system: "Classify this support ticket. Return strict JSON only.",
user: context.customerMessage,
schema: {
category: ["billing", "technical", "account", "other"],
priority: ["low", "medium", "high"],
missingFields: "string[]"
}
});
if (result.missingFields?.length) {
return {
event: "MISSING_INFO",
updates: { missingFields: result.missingFields }
};
}
return {
event: "CLASSIFIED",
updates: {
category: result.category,
priority: result.priority
}
};
}
Expected result: the LLM performs one bounded job. It does not decide whether to email the customer, refund the order, or close the ticket.
Step 5: Add Guards For Production Rules
A guard is a condition that must be true before a transition can happen.
Example: high-priority billing tickets require human review. Low-risk FAQ tickets might skip it.
function requiresHumanReview(context: TicketContext) {
return context.priority === "high" || context.category === "billing";
}
You can use that guard to route after drafting:
async function afterDraft(context: TicketContext) {
if (!context.draftResponse) {
return { event: "FAILED", updates: { error: "No draft response" } };
}
if (requiresHumanReview(context)) {
return { event: "DRAFT_READY" };
}
return { event: "APPROVED" };
}
In a stricter machine, you may split this into separate states:
draft_response
-> risk_check
-> human_review
-> send_response
Expected result: policies live in code, not inside prompt vibes. Your compliance rules should not depend on whether the model is having a confident day.
Step 6: Validate Every LLM Output
Never transition on raw model text.
Bad:
if (llmText.includes("approved")) {
state = "send_response";
}
Better:
const ReviewDecisionSchema = z.object({
decision: z.enum(["approved", "changes_requested", "rejected"]),
notes: z.string().optional()
});
Then parse:
function parseReviewDecision(raw: unknown) {
const parsed = ReviewDecisionSchema.safeParse(raw);
if (!parsed.success) {
return {
event: "FAILED",
updates: { error: "Invalid review decision format" }
};
}
if (parsed.data.decision === "approved") {
return { event: "APPROVED" };
}
if (parsed.data.decision === "changes_requested") {
return {
event: "CHANGES_REQUESTED",
updates: { reviewerNotes: parsed.data.notes }
};
}
return {
event: "REJECTED",
updates: { reviewerNotes: parsed.data.notes }
};
}
Expected result: malformed output becomes a handled workflow event, not a silent mess.
Step 7: Track Retries Like An Adult
Retries are not just “try again.” They need limits, reasons, and different behavior after repeated failure.
function canRetry(context: TicketContext) {
return context.retryCount < 2;
}
function recordFailure(context: TicketContext, error: string) {
return {
...context,
retryCount: context.retryCount + 1,
error
};
}
For a tool call:
async function retrieveCustomerContext(context: TicketContext) {
try {
const customerHistory = await crm.lookupTicketContext(context.ticketId);
return {
event: "CONTEXT_FOUND",
updates: { customerHistory }
};
} catch (error) {
if (canRetry(context)) {
return {
event: "RETRY",
updates: recordFailure(context, "CRM lookup failed")
};
}
return {
event: "CONTEXT_UNAVAILABLE",
updates: { error: "CRM unavailable after retries" }
};
}
}
Expected result: the workflow degrades gracefully. If context retrieval fails, the agent can still draft a cautious response instead of freezing forever.
Step 8: Persist State After Every Transition
If this workflow matters, memory in a process is not enough. Persist the state and context after each transition.
A database record might look like this:
{
"workflowId": "ticket_SUP-1042",
"state": "human_review",
"context": {
"ticketId": "SUP-1042",
"category": "billing",
"priority": "high",
"draftResponse": "I can help investigate the duplicate charge..."
},
"updatedAt": "2026-08-07T10:12:00Z"
}
At minimum, store:
- Workflow ID
- Current state
- Context
- Last event
- Retry count
- Error message
- Timestamps
- Tool call IDs
- Human approval records
Expected result: if your server restarts, the workflow resumes from human_review instead of asking the customer the same question again like a broken vending machine.
Step 9: Add Observability
Logs should tell you what happened without reading the entire conversation.
A useful transition log:
{
"workflowId": "ticket_SUP-1042",
"from": "draft_response",
"event": "DRAFT_READY",
"to": "human_review",
"model": "gpt-5-mini",
"durationMs": 1840,
"toolCalls": ["crm.lookupTicketContext"],
"timestamp": "2026-08-07T10:12:00Z"
}
Track these metrics:
- Time spent per state
- Failure rate per state
- Retry rate per tool
- Human review approval rate
- Number of workflows stuck in non-terminal states
- Average transitions per completed workflow
- Cost per workflow
Expected result: when something breaks, you know where. “The agent is bad” becomes “CRM lookup fails 18% of the time and pushes tickets into fallback drafting.”
That is a fixable problem.
Step 10: Use A Real Framework When The Workflow Grows
For small workflows, a simple state table may be enough. For serious production systems, use a framework that already understands state, transitions, and durability.
XState
XState is useful when you want explicit state machines in JavaScript or TypeScript.
A simplified machine might look like this:
import { createMachine } from "xstate";
export const ticketMachine = createMachine({
id: "supportTicket",
initial: "intake",
context: {
retryCount: 0
},
states: {
intake: {
on: {
RECEIVED: "classify"
}
},
classify: {
on: {
CLASSIFIED: "retrieve_context",
MISSING_INFO: "needs_more_info",
FAILED: "failed"
}
},
needs_more_info: {
on: {
USER_REPLIED: "classify",
TIMEOUT: "failed"
}
},
retrieve_context: {
on: {
CONTEXT_FOUND: "draft_response",
CONTEXT_UNAVAILABLE: "draft_response",
FAILED: "failed"
}
},
draft_response: {
on: {
DRAFT_READY: "human_review",
FAILED: "failed"
}
},
human_review: {
on: {
APPROVED: "send_response",
CHANGES_REQUESTED: "draft_response",
REJECTED: "failed"
}
},
send_response: {
on: {
SENT: "closed",
FAILED: "failed"
}
},
closed: {
type: "final"
},
failed: {
type: "final"
}
}
});
Use this when your app already lives in TypeScript and you want a clean model that developers, product managers, and QA can reason about.
LangGraph
LangGraph is built around graph-based agent workflows. It models state as shared data, nodes as units of work, and edges as routes between those nodes. That maps naturally to agent systems where you need loops, branches, tool calls, and conditional routing.
Use it when your workflow is agent-heavy and you want graph orchestration close to the LLM layer.
AWS Step Functions
AWS Step Functions is a strong fit when workflows involve multiple services, retries, timeouts, approvals, and operational visibility. It defines workflows as state machines and gives you execution history out of the box.
Use it when your process is business-critical and already sits in AWS.
Common Pitfalls
Pitfall 1: Too Many States
Do not create a new state for every tiny internal action.
Bad:
check_email_length
detect_tone
detect_language
detect_urgency
classify_ticket
Better:
classify
Put the small checks inside the state action. Make states represent meaningful workflow stages.
Pitfall 2: Letting The Model Choose Transitions Directly
The model can recommend an event. Your code should validate and emit it.
Bad:
nextState = await llm("What should happen next?");
Better:
const classification = await classifyTicket(context);
const event = validateClassification(classification);
const nextState = transition(currentState, event);
The model is a worker. The state machine is the supervisor.
Pitfall 3: No Terminal Failure State
A workflow that can never fail honestly will fail dishonestly.
Add a failed state. Store the reason. Notify a human when needed.
failed: {
type: "final",
entry: ["logFailure", "notifyOpsIfNeeded"]
}
Failure is not embarrassing. Invisible failure is.
Pitfall 4: No Versioning
Your workflow will change. Persist the version with every run.
{
"workflowVersion": "2026-08-07.1",
"state": "human_review"
}
Without versioning, old workflows may resume under new rules and behave strangely.
Pitfall 5: Treating Human Review As A Comment Box
Human review should emit structured events.
type HumanReviewEvent =
| { type: "APPROVED" }
| { type: "CHANGES_REQUESTED"; notes: string }
| { type: "REJECTED"; reason: string };
A Slack thumbs-up is not a production approval system unless you capture it as durable workflow data.
Production Checklist
Before shipping an agent state machine, check this:
- Every state has one clear responsibility
- Every transition is named
- Every LLM output is schema-validated
- Every tool call has timeout and retry behavior
- Every workflow has terminal success and failure states
- State is persisted after each transition
- Human approval is structured and auditable
- Workflow versions are stored
- Stuck workflows can be detected
- Logs show state, event, next state, latency, and error reason
- Sensitive actions require explicit approval
If you cannot answer “what state is this agent in right now?”, you are not production-ready. You are running an improv show with API keys.
Where State Machines Fit With Modern Agent Design
State machines do not make agents less intelligent. They make them less chaotic.
The model still handles fuzzy tasks:
- Understanding messy user messages
- Summarizing context
- Choosing likely categories
- Drafting responses
- Extracting structured data
- Explaining tradeoffs
The machine handles control:
- Routing
- Retries
- Approvals
- Persistence
- Policy
- Exit conditions
That split is the mature pattern. Let the LLM handle language and judgment. Let deterministic code handle the process.
The Takeaway
If your agent only chats, a prompt may be enough. If your agent does work, use a state machine.
Start small. Pick one workflow that currently feels fragile. Write down the states. Name the events. Add validation. Persist progress. Log transitions. Put humans where the risk demands it.
The win is not elegance. The win is that your agent can no longer lose the plot quietly.
That is what production means.
Sources
> Want more like this?
Get the best AI insights delivered weekly.
> Related Articles
AI Agent Approval Workflows: Put Humans at the Right Control Points
Human approval can make an agent safer—or merely slower. Design checkpoints around irreversible actions, changing risk, and evidence people can actually review.
LLM Trace Redaction in Production: Debug Without Logging Private Data
LLM traces are debugging gold and privacy dynamite. Capture structure, decisions, and timing while removing secrets and personal data before storage.
Secret Management for AI Agents: Stop Leaking Credentials Into Prompts
An agent needs tools, not a backpack full of API keys. Keep secrets outside model context, issue short-lived capability tokens, and audit every use.
Tags
> Stay in the loop
Weekly AI tools & insights.