n4nAI

State machines for AI agents: a practical guide

A practical guide to building state machine AI agents with explicit states, transitions, checkpointing, and error handling for production deployments.

n4n Team3 min read766 words

Audio narration

Coming soon — every post will get a voice note here.

Most agent frameworks hide control flow behind opaque loops. Building state machine AI agents makes the control flow explicit, debuggable, and resilient to partial failures. This guide walks through a concrete design and implementation path for production-grade agent workflows.

Define the state schema first

Start by enumerating every condition the agent can be in. A state is not “thinking” or “doing”; it is a precise snapshot of progress. Use a strict schema so you can serialize and inspect it.

from enum import Enum
from pydantic import BaseModel

class AgentState(str, Enum):
    IDLE = "idle"
    PLANNING = "planning"
    TOOL_CALL = "tool_call"
    EVALUATING = "evaluating"
    RECOVERING = "recovering"
    DONE = "done"
    FAILED = "failed"

class AgentSnapshot(BaseModel):
    run_id: str
    state: AgentState
    step: int
    plan: list[str] = []
    last_tool_result: dict | None = None
    error: str | None = None

The schema is the contract between your state machine and your storage layer. Do not store LLM prompts or raw responses inside the hot state path unless they are needed for resumption. Keep them in an append-only event log.

Map transitions to deterministic guards

A transition happens only when a guard returns true. Guards are pure functions over the snapshot and the event. Never let the LLM pick the next state directly; let it emit a proposed action, then validate.

def guard_planning_to_tool_call(snap: AgentSnapshot, evt: dict) -> bool:
    return snap.state == AgentState.PLANNING and evt.get("tool") is not None

TRANSITIONS = {
    (AgentState.IDLE, AgentState.PLANNING): lambda s, e: e.get("start") is True,
    (AgentState.PLANNING, AgentState.TOOL_CALL): guard_planning_to_tool_call,
    (AgentState.TOOL_CALL, AgentState.EVALUATING): lambda s, e: e.get("tool_done"),
    (AgentState.EVALUATING, AgentState.PLANNING): lambda s, e: not e.get("solved"),
    (AgentState.EVALUATING, AgentState.DONE): lambda s, e: e.get("solved"),
    (AgentState.TOOL_CALL, AgentState.RECOVERING): lambda s, e: e.get("error") is not None,
}

This table is the entire control flow. You can unit test each guard without mocking a model. State machine AI agents live or die by how strictly you enforce these edges.

Persist state with checkpointing

Every accepted transition writes a new snapshot with an incremented step counter. Use optimistic concurrency to avoid lost updates.

def apply_event(snap: AgentSnapshot, evt: dict, store) -> AgentSnapshot:
    next_state = next_state_from_event(snap, evt)
    guard = TRANSITIONS.get((snap.state, next_state))
    if not guard or not guard(snap, evt):
        raise IllegalTransition(snap.state, evt)
    new_snap = snap.model_copy(deep=True)
    new_snap.step += 1
    new_snap.state = next_state
    store.save(new_snap.run_id, new_snap.step, new_snap)
    return new_snap

Checkpointing means a process crash mid-tool-call resumes from TOOL_CALL with the same run_id, not from scratch. Store the snapshot in Postgres or DynamoDB with a conditional write on step. If the write fails, the event is retried by the dispatcher.

Handle LLM calls as side effects, not state

The model inference is an external effect. Trigger it from a worker when the state is PLANNING, but the call itself is not part of the state machine. The state only records that a planning request was issued and later a response event arrived.

def on_enter_planning(snap: AgentSnapshot, queue):
    queue.publish("llm_request", {
        "run_id": snap.run_id,
        "prompt": build_prompt(snap),
        "state_step": snap.step,
    })

When the response returns, wrap it as an event and call apply_event. If the worker died, the state is still PLANNING and a timeout re-publishes the request. This separation is what lets state machine AI agents survive provider outages.

Implement fallback and routing for model calls

Model providers fail. Your planning worker should not hardcode one endpoint. Route through a gateway that honors client routing directives and automatically falls back when a provider is rate-limited. For example, an OpenAI-compatible request can carry a routing hint and cache-control:

{
  "model": "anthropic/claude-3.5-sonnet",
  "messages": [{"role": "user", "content": "plan next step"}],
  "route": {"prefer": ["openai/gpt-4o", "meta/llama-3.1-70b"]},
  "cache": {"ttl": 300}
}

A gateway such as n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models with per-token metering and forwards those hints, so your agent code stays model-agnostic. The state machine does not care which model answered; it only cares that a planning event arrived.

Test with replay and property checks

Record every event and snapshot. In CI, replay production traces by feeding events through apply_event and assert no illegal transitions occur. Property-based tests should verify:

  • step is strictly increasing.
  • FAILED is terminal unless explicitly reset by an operator event.
  • DONE never transitions to PLANNING.
def test_replay(trace):
    snap = AgentSnapshot(run_id="test", state=AgentState.IDLE, step=0)
    for evt in trace:
        prev = snap.step
        snap = apply_event(snap, evt, FakeStore())
        assert snap.step == prev + 1

Replay catches nondeterministic guard bugs that unit tests miss. State machine AI agents earn trust when you can demonstrate every past incident replays cleanly.

Common pitfalls and tradeoffs

Too many states. Engineers model UI moods as states. Resist. If two states differ only by a flag, use a field, not a state. More states mean more guards and more surface for bugs.

Ignoring idempotency. Event delivery is at-least-once. Your apply_event must reject duplicate (run_id, step, event_hash) pairs. Otherwise a retry double-advances.

Mixing business logic into guards. A guard should answer “is this transition legal?” not “what is the best tool to call?” Keep selection logic in the worker, not the state machine core.

Synchronous LLM waits. Blocking the state machine on a model response kills throughput. Emit, persist, and move on. The event loop picks up the response later.

No dead-letter path. When RECOVERING cannot resolve, transition to FAILED with an error field. A separate supervisor alerts humans. Do not let agents spin in RECOVERING forever.

State machine AI agents trade upfront schema design for operational sanity. You write more boilerplate early; you page less at 3 a.m. The pattern scales from a single planning loop to multi-agent orchestration because the invariants are explicit and the persistence layer is boring.

Where to start tomorrow

Pick one existing agent loop in your codebase. Draw its states on paper. Chances are you will find implicit states like “waiting for retry” that are currently hidden in local variables. Encode them. Add a step counter and a store write. That single change converts a fragile script into a resumable state machine AI agent without rewriting the model calls.

From there, layer guards, then checkpointing, then replay tests. The path is incremental and each step is independently deployable.

Tagsstate-machineagent-designstate-managementai-agents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All agent state management & checkpointing posts →