kc
EssayArchitecture
proposed approved executing done gate run rejected — back to proposed

Your agent needs a state machine, not a while-loop

KC·Jun 23, 2026·9 min read

The model should propose actions. It should never be the thing that takes them. That decision belongs to a state machine you control.

Almost every agent demo you have seen is the same five lines:

while (!done) {
  const thought = await model.think(context);
  const result = await act(thought);
  context.push(thought, result);
  done = thought.isFinal;
}

It works in a notebook. It falls apart in production for one structural reason: the loop has no state you can name. There is no moment you can point to and say "the agent is here, it wants to do this, and nothing has happened yet." The only state is a growing array and a boolean, and by the time you can read them, the side effect already fired.

Why the naive loop fails

Three failures follow directly from that missing structure.

No observability. While the loop runs, the agent is a black box between think and act. You cannot answer "what is it about to do," because the only representation of intent is buried inside an opaque model call. Logs after the fact are not the same as a state you can query before the action commits.

No safe stop. Interrupting a while loop means killing the process, and a kill can land halfway through a multi-part action: the database row written, the confirmation email not sent. The loop has no notion of a step being atomic, so it has no notion of where a stop is safe.

No gate before irreversible actions. This is the one that ends careers. act(thought) treats refunding a customer, dropping a table, and reading a file as the same kind of thing — a function call. The model decided, and the loop dutifully did it. There is no seam between deciding and doing where a human, a policy, or a budget check could intervene. The model is both the brain and the trigger finger.

The model proposes. The harness disposes. Reliability is not a smarter model — it is refusing to let the model pull the trigger.

Model the agent as a finite state machine

Replace the loop with an explicit set of states and the transitions between them. An action does not go straight from idea to execution. It moves through named states, and the harness owns every transition:

// proposed ──approve──▶ approved ──execute──▶ executing ──▶ done
//     │
//     └──reject──▶ rejected

The model's only job is to produce a proposed action. Everything after that is admission control: the harness inspects the proposal, applies policy, and decides whether the transition to approved may fire. An irreversible action can require a human to authorize that one transition. A reversible one auto-approves. Either way, the decision lives in code you control, not in a token stream.

type Action =
  | { kind: "read_file"; path: string }
  | { kind: "refund"; customerId: string; cents: number };

type Phase = "proposed" | "approved" | "executing" | "done" | "rejected";

interface Step {
  id: string;
  action: Action;
  phase: Phase;
  reason?: string;
}

interface Decision {
  allow: boolean;
  reason: string;
  requiresHuman: boolean;
}

const IRREVERSIBLE = new Set<Action["kind"]>(["refund"]);

// Admission control: the harness decides, the model does not.
function admit(action: Action): Decision {
  if (IRREVERSIBLE.has(action.kind)) {
    return { allow: true, reason: "irreversible", requiresHuman: true };
  }
  return { allow: true, reason: "reversible", requiresHuman: false };
}

class Agent {
  constructor(
    private readonly model: { propose(ctx: Context): Promise<Action> },
    private readonly gate: { awaitApproval(step: Step): Promise<boolean> },
    private readonly run: (action: Action) => Promise<void>,
  ) {}

  async step(ctx: Context): Promise<Step> {
    // 1. The model PROPOSES. It cannot reach past this line.
    const action = await this.model.propose(ctx);
    const step: Step = { id: crypto.randomUUID(), action, phase: "proposed" };

    // 2. The harness DECIDES whether the transition may fire.
    const decision = admit(action);
    if (!decision.allow) {
      return { ...step, phase: "rejected", reason: decision.reason };
    }

    // 3. Irreversible transitions block on an explicit gate.
    if (decision.requiresHuman) {
      const approved = await this.gate.awaitApproval(step);
      if (!approved) return { ...step, phase: "rejected", reason: "denied" };
    }

    // 4. Only now does the action execute, in a named state.
    const executing: Step = { ...step, phase: "executing" };
    await this.run(executing.action);
    return { ...executing, phase: "done" };
  }
}

Notice what this buys you. propose returns a typed Action you can log, render in a UI, or diff against a policy before anything happens. The awaitApproval gate is a real suspension point — the run can sit in proposed for a millisecond or a week, and resuming is just calling step again. Stopping is well-defined: you stop between steps, never mid-transition.

Control flow is where reliability lives

The model is the least reliable component in your system and the one you control least. So push the reliability into the part you do control: the transition function. Every property you want from a production agent — audit trails, human approval, spend limits, kill switches, replay — is a property of the state machine around the model, not the model itself.

A while loop has exactly one transition, think → act, and it is unguarded. A state machine gives you a seam at every transition, and a seam is where policy goes. Make the states explicit, let the harness own the transitions, and let the model do the one thing it is good at: proposing what to try next. Deciding whether to try it is your job.

Read next · Essay
Agents don't get dumber — their context rots
← Browse all