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

> Unify execution and business state in agent architectures by merging them into a single Thread object. Learn this 12-factor approach to simplify your agent development and improve clarity.

- Repository: [HumanLayer/12-factor-agents](https://github.com/humanlayer/12-factor-agents)
- Tags: architecture
- Published: 2026-05-19

---

**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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/workshops/2025-05/sections/final/src/agent.ts), the Thread implementation looks like this:

```ts
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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:

```ts
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`](https://github.com/humanlayer/12-factor-agents/blob/main/workshops/2025-05/sections/final/src/agent.ts) shows how to process events until a terminal intent appears:

```ts
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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.