How to Unify Execution State and Business State in Agent Architectures: A 12-Factor Approach

Merge execution state and business state into a single Thread object that stores the complete conversation history, deriving execution cues like "awaiting approval" by inspecting the last event rather than maintaining separate state variables.

Most agent frameworks force you to juggle separate data structures for execution metadata and business logic history. According to the 12-Factor Agents specification in the humanlayer/12-factor-agents repository, you should unify execution state and business state into one serialized Thread to simplify debugging, improve recovery, and eliminate state synchronization bugs.

Why Dual State Management Fails

Traditional agent architectures split state into two silos: execution state (current step, retry counters, wait flags) and business state (the message history, tool calls, and results). This separation requires complex synchronization logic, makes debugging difficult because you must correlate two data sources, and complicates resumption after crashes. As defined in content/factor-05-unify-execution-state.md, execution state is really just "metadata about what has happened so far" and can be derived from the complete history of events.

The Thread-as-Source-of-Truth Pattern

The 12-Factor Agents methodology treats the Thread as the single source of truth. Instead of storing "status: awaiting_human_approval" in a separate variable, you inspect the last event in the thread to determine what the agent should do next. This unified approach delivers:

  • Simplicity: One data structure to persist, query, and debug
  • Full visibility: The entire execution path is human-readable in the event log
  • Zero-downtime recovery: Resume from any point by loading the thread
  • Forking flexibility: Copy a subset of events to spawn parallel conversations
  • Portability: Swap storage backends without touching agent logic

Modeling the Thread and Events

Represent the entire conversation as a Thread class containing an ordered list of Event objects. Each event captures user messages, tool calls, tool responses, or control instructions. In workshops/2025-05/sections/final/src/agent.ts, the Thread implementation looks like this:

export class Thread {
    events: Event[] = [];

    constructor(events: Event[]) {
        this.events = events;
    }

    // Convert the whole thread into a prompt for the LLM
    serializeForLLM() {
        return this.events.map(e => this.serializeOneEvent(e)).join("\n");
    }

    // Execution-state helpers
    awaitingHumanResponse(): boolean {
        const lastEvent = this.events[this.events.length - 1];
        return ['request_more_information', 'done_for_now'].includes(lastEvent.data.intent);
    }

    awaitingHumanApproval(): boolean {
        const lastEvent = this.events[this.events.length - 1];
        return lastEvent.data.intent === 'divide';
    }
}

Notice how awaitingHumanResponse() and awaitingHumanApproval() derive execution state by analyzing the last event's intent rather than checking an external status flag.

Serializing State for the LLM

When prompting the model, you must convert the Thread into a deterministic text format. The serializeForLLM() method maps the internal event objects into a string representation that includes both business context (previous tool results) and execution cues (pending approvals). This ensures the LLM sees the complete picture—including why the agent stopped previously—in a single call.

Persisting the Unified Thread

Store the Thread using a lightweight abstraction that maps UUIDs to Thread instances. The ThreadStore in workshops/2025-05/sections/final/src/state.ts demonstrates a minimal implementation that can be replaced with Redis, PostgreSQL, or SQLite without modifying agent logic:

import crypto from 'crypto';
import { Thread } from '../src/agent';

export class ThreadStore {
    private threads: Map<string, Thread> = new Map();

    // Create a new thread and return its ID
    create(thread: Thread): string {
        const id = crypto.randomUUID();
        this.threads.set(id, thread);
        return id;
    }

    // Retrieve a stored thread
    get(id: string): Thread | undefined {
        return this.threads.get(id);
    }

    // Update an existing thread after a tool call
    update(id: string, thread: Thread): void {
        this.threads.set(id, thread);
    }
}

Because execution state lives inside the Thread, you never need to synchronize separate tables or worry about stale status flags when resuming.

Running the Agent Loop Without External State

The agent loop reads only from the Thread to decide what happens next. No external state machine tracks "current step." The implementation in workshops/2025-05/sections/final/src/agent.ts shows how to process events until a terminal intent appears:

export async function agentLoop(thread: Thread): Promise<Thread> {
    while (true) {
        const nextStep = await b.DetermineNextStep(thread.serializeForLLM());

        thread.events.push({ type: "tool_call", data: nextStep });

        switch (nextStep.intent) {
            case "done_for_now":
            case "request_more_information":
                // Human-visible response; stop processing
                return thread;
            case "divide":
                // Requires human approval; stop processing
                return thread;
            default:
                // Execute arithmetic tool and continue
                thread = await handleNextStep(nextStep, thread);
        }
    }
}

When the loop returns, the Thread contains the complete history, including the halt reason. To resume, simply reload the Thread from the ThreadStore and re-enter the loop.

Summary

  • Unify execution state and business state by storing the complete conversation history in a single Thread object.
  • Derive execution cues like awaitingHumanApproval() by inspecting the last event in the thread rather than maintaining separate metadata.
  • Serialize the entire Thread for the LLM using deterministic methods like serializeForLLM() to ensure complete context.
  • Persist via simple abstractions like the ThreadStore class, enabling you to swap in-memory maps for production databases without code changes.
  • Resume and fork conversations by copying Thread events, eliminating complex state reconstruction logic.

Frequently Asked Questions

What is the difference between execution state and business state in AI agents?

Execution state tracks where the agent is in its workflow—such as "waiting for human approval" or "retry attempt 2." Business state is the historical record of what has happened, including user messages, tool inputs, and tool outputs. The 12-Factor Agents approach stores only business state (the Thread) and derives execution state on-the-fly by examining the most recent events.

How does the Thread pattern improve debugging and observability?

Because the Thread contains every event in chronological order, you can reconstruct the exact execution path by reading the array. There is no hidden state in external variables or caches. As noted in content/factor-05-unify-execution-state.md, this provides "full visibility" and "human-readable observability" because the thread is a complete audit log of the agent's decision-making process.

Can I use a database like PostgreSQL or Redis instead of the in-memory ThreadStore?

Yes. The ThreadStore interface is intentionally minimal—implement create(), get(), and update() methods to serialize the Thread to any backend. The agent logic in agentLoop() remains unchanged whether the Thread is stored in memory, Redis, or a durable SQL database, as long as the Thread object can be retrieved by ID.

How do you handle branching or parallel execution with unified state?

Forking a conversation is trivial when execution state lives in the Thread. To branch, create a new Thread instance containing a subset of events from the original (e.g., new Thread(original.events.slice(0, 5))). This copy-on-write pattern allows parallel exploration of different agent paths without complex state cloning or synchronization mechanisms.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →