An autonomous agent loop is an attractive prototype: give a model a goal and tools, append each result to the conversation, and let it decide what to do next until it declares success. The same freedom becomes a liability in production. After a timeout, nobody knows whether a tool ran. A prompt change silently alters control flow. An approval may refer to an earlier proposal. A long conversation becomes the only state record, and recovery means asking the model to reconstruct what happened.

A production workflow needs a different center of gravity. The application owns an explicit state machine; the model proposes structured facts or choices within selected transitions. Every external effect is bounded, authorized, and recorded. Human approval is a durable event tied to an exact proposal, not a vague pause in a chat. The workflow can stop, resume on another worker, survive duplicate delivery, and explain why it reached its current state.

This is still agentic in the useful sense: a model can interpret unstructured evidence and choose among allowed next actions. It is not autonomous in the sense of inventing its own lifecycle. The state machine supplies the lifecycle, which is precisely what makes consequential automation operable.

Replace the Open Loop with a Transition System

An open loop usually resembles observe -> reason -> act -> repeat. Its termination rule, retry behavior, and legal actions live inside prompt prose. The transcript mixes evidence, temporary reasoning, tool results, and control state. That representation is difficult to query and impossible to constrain reliably.

A state machine makes the control contract explicit. Let SS be a finite set of workflow states, EE a set of accepted events, and TT a transition function:

T:S×ES×CT: S \times E \rightarrow S \times C

Here CC is a set of commands to perform after the transition is persisted. A command might request model inference, retrieve a record, notify a reviewer, or execute an approved change. The transition function does not perform those effects; it decides which effects are allowed from the current state.

That separation establishes useful invariants:

  • A workflow can enter only named states through reviewed transitions.
  • Unexpected events are rejected or recorded without changing state.
  • A model response cannot directly invoke an unlisted tool.
  • A committed state records why each command was issued.
  • Terminal states are explicit and cannot accidentally restart.

Finite does not mean simplistic. State can include structured context, attempt counters, evidence references, proposal versions, deadlines, and errors. The number of control states should remain understandable, while data captures the case-specific variation. Do not create one state per possible model sentence.

An event-driven machine also changes the retry model. A worker does not ask, “What should I do now?” after a crash. It reloads a versioned state, observes whether the intended command has a recorded outcome, and continues according to a deterministic transition. The model may help decide a domain question, but it never has to infer the workflow’s history from prose.

Design States, Events, and Invariants Together

Begin with business commitments rather than model calls. Consider a workflow for changing a supplier’s bank account. Fraud risk requires independent evidence and human approval before the accounting system is updated. Useful control states might be:

State Meaning Accepted next events
collecting_evidence Required documents or checks are incomplete evidence received, deadline elapsed, cancel
analyzing A bounded model analysis is outstanding analysis completed, analysis failed
needs_clarification A specific evidence gap blocks progress evidence received, deadline elapsed, cancel
awaiting_approval An immutable proposal is ready for review approve, reject, proposal invalidated
executing An approved command is outstanding execution succeeded, execution failed
completed The exact approved change was applied none
rejected A reviewer or policy rejected the request none
failed Recovery policy was exhausted operator retry or cancel, if explicitly allowed

Write invariants beside the table. awaiting_approval requires a proposal hash and evidence snapshot. executing requires an approval for that same hash, an unexpired authorization, and an idempotency key. completed requires the external system’s durable receipt. These statements become runtime assertions and test properties.

Events should describe facts that occurred, not imperative wishes. AnalysisCompleted is an event; RunAnalysis is a command. ApprovalGranted records a reviewer decision; RequestApproval is a notification command. Keeping those vocabularies distinct prevents an effect request from being mistaken for evidence that the effect succeeded.

Store enough data to evaluate future transitions without rereading an entire transcript. Model input and output can be retained as referenced artifacts for audit, but canonical fields such as supplier ID, proposed account fingerprint, evidence IDs, risk flags, and proposal version belong in typed workflow state. Sensitive values should be encrypted or tokenized, and logs should use stable references rather than copying raw account details.

Keep the Model Inside Bounded Decision Steps

The model should receive a task-specific contract at a named transition. In analyzing, it may compare submitted documents, extract stated account details, classify inconsistencies, and return one of three outcomes: evidence sufficient, clarification required, or suspected fraud. It cannot choose to update the accounting system, email an arbitrary address, or redefine the approval policy.

A strict output schema might include:

json
{
  "outcome": "needs_clarification",
  "missingEvidence": ["independent contact confirmation"],
  "riskFlags": ["request-origin-does-not-match-vendor-record"],
  "evidenceRefs": ["document-17", "vendor-record-4"],
  "summary": "The requested account is documented, but the request origin differs from the verified contact."
}

Application code validates the schema, verifies every evidence reference belongs to the workflow, and applies deterministic policy. A risk flag may force rejection or specialist review regardless of the model’s preferred outcome. Unknown fields and outcomes fail closed. The workflow records the model, prompt, schema, evidence snapshot, and result versions so a later audit can reconstruct the decision boundary.

Bounded tools are commands with narrow capabilities. A lookup command reads one supplier by ID. A notification command sends a template to the verified contact. The final update command can change only the approved bank-account field for one supplier and must include the expected current record version. General database access, arbitrary HTTP requests, and broad credentials are not model tools.

Keep control logic out of natural-language scratch space. The model may provide a concise rationale for reviewers, but attempt counts, deadlines, required approver roles, and legal transitions remain deterministic data. If model reasoning is unavailable or intentionally not retained, the workflow still has enough structured evidence to explain every application decision.

Implement a Pure Reducer and Deferred Commands

A small reducer makes the ownership boundary concrete. It accepts current state and an event, checks invariants, and returns new state plus commands. Persistence and effect execution live outside it.

ts
type Workflow =
  | { kind: 'collecting_evidence'; version: number; evidenceIds: string[] }
  | { kind: 'analyzing'; version: number; evidenceIds: string[]; attempt: number }
  | { kind: 'needs_clarification'; version: number; evidenceIds: string[]; missing: string[] }
  | { kind: 'awaiting_approval'; version: number; proposalHash: string; expiresAt: string }
  | { kind: 'executing'; version: number; proposalHash: string; idempotencyKey: string }
  | { kind: 'completed'; version: number; receiptId: string }
  | { kind: 'rejected'; version: number; reason: string }
  | { kind: 'failed'; version: number; reason: string };

type Event =
  | { type: 'EvidenceReady'; evidenceIds: string[] }
  | { type: 'AnalysisCompleted'; outcome: 'clarify'; missing: string[] }
  | { type: 'AnalysisCompleted'; outcome: 'propose'; proposalHash: string; expiresAt: string }
  | { type: 'AnalysisFailed'; retryable: boolean }
  | { type: 'ApprovalGranted'; proposalHash: string; reviewerId: string }
  | { type: 'ApprovalRejected'; proposalHash: string; reason: string }
  | { type: 'ExecutionSucceeded'; receiptId: string }
  | { type: 'ExecutionFailed'; retryable: boolean; reason: string };

type Command =
  | { type: 'AnalyzeEvidence'; evidenceIds: string[]; attempt: number }
  | { type: 'NotifyReviewer'; proposalHash: string }
  | { type: 'ApplyBankChange'; proposalHash: string; idempotencyKey: string };

function transition(state: Workflow, event: Event): { state: Workflow; commands: Command[] } {
  const version = state.version + 1;

  if (state.kind === 'collecting_evidence' && event.type === 'EvidenceReady') {
    return {
      state: { kind: 'analyzing', version, evidenceIds: event.evidenceIds, attempt: 1 },
      commands: [{ type: 'AnalyzeEvidence', evidenceIds: event.evidenceIds, attempt: 1 }],
    };
  }

  if (state.kind === 'analyzing' && event.type === 'AnalysisCompleted') {
    if (event.outcome === 'clarify') {
      return {
        state: { kind: 'needs_clarification', version, evidenceIds: state.evidenceIds, missing: event.missing },
        commands: [],
      };
    }
    return {
      state: { kind: 'awaiting_approval', version, proposalHash: event.proposalHash, expiresAt: event.expiresAt },
      commands: [{ type: 'NotifyReviewer', proposalHash: event.proposalHash }],
    };
  }

  if (state.kind === 'awaiting_approval' && event.type === 'ApprovalGranted') {
    if (event.proposalHash !== state.proposalHash) throw new Error('Approval does not match proposal');
    const idempotencyKey = `bank-change:${event.proposalHash}`;
    return {
      state: { kind: 'executing', version, proposalHash: state.proposalHash, idempotencyKey },
      commands: [{ type: 'ApplyBankChange', proposalHash: state.proposalHash, idempotencyKey }],
    };
  }

  if (state.kind === 'executing' && event.type === 'ExecutionSucceeded') {
    return { state: { kind: 'completed', version, receiptId: event.receiptId }, commands: [] };
  }

  throw new Error(`Illegal event ${event.type} in state ${state.kind}`);
}

The abbreviated reducer intentionally omits some table transitions, which production code must implement or reject explicitly. Its important property is purity. In one database transaction, the runtime checks the expected workflow version, appends the accepted event, writes the new state, and inserts commands into an outbox. Separate workers claim outbox rows and report outcomes as new events.

This transaction closes the dangerous gap between state and effects. If a process crashes after commit but before dispatch, the command remains in the outbox. If delivery happens twice, the effect handler uses the command ID or domain idempotency key to return the original result. The reducer never assumes that sending a command proves completion.

Bind Human Approval to the Exact Proposal

“A person approved this workflow” is too weak. Approval must cover an immutable artifact: normalized target values, affected resource, evidence snapshot, policy version, and requested operation. Canonicalize that artifact and hash it. Display human-readable details to the reviewer, but store the hash in both the approval event and subsequent command.

The review screen should explain what will change, why the workflow stopped, which evidence supports it, which checks ran, and what uncertainty remains. It should not bury the effective bank-account fingerprint beneath model prose. Reviewers need explicit approve and reject actions; editing the proposal creates a new version and invalidates the prior approval.

Authorization is evaluated when approval is submitted and again before execution. The reviewer must have the required role for the supplier or region, must not violate separation-of-duty rules, and may need step-up authentication. Approval expires when policy demands it or when referenced evidence, supplier state, or proposal content changes.

Record rejection reasons as structured codes plus optional comments. They help operators distinguish insufficient evidence, incorrect extraction, policy denial, and suspected fraud. Rejection should enter a terminal state unless the process explicitly supports a new request; silently sending the same proposal back through the model invites approval fatigue.

Human checkpoints are scarce capacity. Place them at irreversible or high-impact boundaries, not after every model call. Deterministic low-risk corrections can proceed automatically, while novel conditions or conflicting evidence route to review. Measure queue age and reviewer load so a supposedly safe workflow does not become an unbounded operational backlog.

Trace the Worked Example Through Failure and Resume

The supplier request arrives with a letter and a verified-contact mismatch. The machine follows this path:

stateDiagram-v2 [*] --> CollectingEvidence CollectingEvidence --> Analyzing: EvidenceReady Analyzing --> NeedsClarification: Missing independent confirmation NeedsClarification --> Analyzing: EvidenceReady Analyzing --> AwaitingApproval: ProposalCreated AwaitingApproval --> Executing: ApprovalGranted for proposal hash AwaitingApproval --> Rejected: ApprovalRejected Executing --> Completed: External receipt recorded Executing --> Failed: Recovery policy exhausted

First, analysis returns needs_clarification with the verified callback as a missing item. The workflow sends a fixed notification command and waits; it does not let the model invent another contact channel. When confirmation arrives, an EvidenceReady event references the expanded evidence set and analysis runs again.

The second result produces proposal hash p42. A reviewer approves p42, but the worker crashes after the accounting API accepts the update and before it records success. The outbox redelivers ApplyBankChange(p42) to another worker. The accounting adapter sends the same idempotency key and expected supplier-record version. It receives the original receipt, then emits ExecutionSucceeded. The machine reaches completed exactly once from the workflow’s perspective.

Now consider a concurrent edit. If the supplier record changed after approval, the expected-version check fails. That is not a generic transient error. The execution adapter emits a conflict event, invalidating p42 and returning the workflow to a state that requires renewed analysis or review. Reusing the old approval would authorize a proposal against obsolete facts.

At every point, an operator can answer: current state, accepted events, outstanding commands, proposal version, approver, external receipt, and next legal actions. No model call is required to reconstruct that account.

Make Concurrency, Retries, and Recovery Explicit

At-least-once event and command delivery is a practical default. Give every event a unique ID, every workflow an optimistic version, and every effect a stable idempotency key. A duplicate event with the same ID returns the previously committed result. Two distinct events racing against version 7 cannot both install version 8; the loser reloads and determines whether its fact is still relevant.

Retries belong to commands, not arbitrary states. Define per-command maximum attempts, backoff, deadline, and retryable error classes. A provider timeout during analysis can retry with the same evidence snapshot. A schema-invalid model response may allow one repair attempt before entering a review or failure state. A policy rejection is not retryable. External-effect ambiguity requires querying by idempotency key before issuing another write.

Timers should be durable events scheduled by the workflow runtime. ApprovalExpired or ClarificationDeadlineElapsed can race with a human action, so transition rules decide which event wins based on committed order and timestamps. Do not rely on an in-memory sleep owned by one process.

Version the state-machine definition. In-flight workflows may remain on the version under which they started, or run through explicit migrations that map old state and data to new invariants. Never reinterpret historical events under changed prompt or policy semantics without recording the migration. A replay tool should be able to rebuild canonical state from the event log and compare it with the stored projection.

Test Transitions and Operate the Workflow

Test the reducer as a mathematical object before testing models. Table-driven tests should cover every allowed state-event pair and representative illegal pairs. Assert invariants after every accepted transition: execution always has matching approval, terminal states issue no commands, attempts remain bounded, and a changed proposal cannot reuse approval.

Property-based tests can generate event sequences and verify that no sequence reaches completed without an ExecutionSucceeded receipt following a matching approval. Model-based tests can compare the implementation against a simpler transition table. Inject crashes at every boundary: before transaction commit, after outbox commit, during command handling, after external success, and before outcome recording. Deliver duplicate and reordered events to confirm idempotency and version checks.

Model steps need contract tests with fixed evidence fixtures, schema-invalid responses, unsupported references, ambiguity, and prompt-injection text inside documents. Evaluate their domain quality separately, but keep state-machine safety independent: even an adversarial valid response must be unable to create an illegal command.

Operational dashboards should count workflows by state and age, command attempts, approval queue time, expiration, failure code, model configuration, and transition latency. Alert on stuck-state age rather than merely worker errors. Provide operators with audited commands to retry a failed effect, supply evidence, reject, or cancel; direct database edits destroy the very history the design is meant to preserve.

The tradeoff is deliberate structure. A state machine requires schema design, migrations, an outbox, idempotent adapters, and careful review UX. It is less flexible than letting a model improvise. That is an advantage wherever actions have cost, authority, or a long lifetime. Keep purely exploratory, read-only assistance outside the workflow when durable control is unnecessary. Once an AI process can wait, resume, spend, notify, or mutate records, explicit states and legal transitions are the simpler system to trust.