# Threads, turns, and items

> Core primitives (`Thread`, `Turn`, `ThreadItem`), subscription model, thread status states, ephemeral threads, and how persisted rollouts relate to in-memory loaded threads.

- Repository: openai/codex
- GitHub: https://github.com/openai/codex
- Human docs: https://grok-wiki.com/public/docs/openai-codex-c82680b15ec1
- Complete Markdown: https://grok-wiki.com/public/docs/openai-codex-c82680b15ec1/llms-full.txt

## Source Files

- `README.md`
- `src/thread_state.rs`
- `src/thread_status.rs`
- `src/request_processors/thread_processor.rs`
- `src/request_processors/turn_processor.rs`
- `src/request_processors/thread_lifecycle.rs`
- `src/bespoke_event_handling.rs`

---

---
title: "Threads, turns, and items"
description: "Core primitives (`Thread`, `Turn`, `ThreadItem`), subscription model, thread status states, ephemeral threads, and how persisted rollouts relate to in-memory loaded threads."
---

`codex app-server` models every client conversation as a **thread** of **turns**, each turn composed of typed **items** streamed over JSON-RPC notifications. The v2 wire types live in `codex-app-server-protocol`; runtime ownership splits between in-memory `CodexThread` instances (`ThreadManager`), per-connection subscriptions (`ThreadStateManager`), and on-disk rollout JSONL (`thread_store` / `thread/list`).

## Thread, turn, and item

| Primitive | Role | Typical RPC / notification |
|-----------|------|---------------------------|
| `Thread` | One user↔agent conversation; holds metadata, optional `turns[]`, runtime `status` | `thread/start`, `thread/resume`, `thread/read`, `thread/started` |
| `Turn` | One exchange (user input → agent completion); owns `items[]` and `status` | `turn/start`, `turn/started`, `turn/completed` |
| `ThreadItem` | Atomic transcript unit inside a turn (message, tool, patch, etc.) | `item/started`, `item/completed`, `item/*/delta` |

A **thread** is identified by `id` (UUID string), shares a `sessionId` with forked siblings, and may reference `forkedFromId` or `parentThreadId` (subagents). Important fields on the wire `Thread` object:

- `preview` — usually the first user message when known
- `ephemeral` — in-memory-only session; `path` is `null` when true
- `status` — runtime load/activity state (see below)
- `path` — rollout file on disk when persisted
- `turns` — populated only on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` with `includeTurns: true`; otherwise an empty list on other responses

A **turn** carries `id`, `status`, timestamps, optional `error`, and `items`. `itemsView` tells clients how much history was loaded: `notLoaded`, `summary`, or `full` (default for persisted full history).

**Turn status** values:

| `TurnStatus` | Meaning |
|--------------|---------|
| `inProgress` | Turn is running (returned immediately from `turn/start`; confirmed by `turn/started`) |
| `completed` | Finished successfully |
| `interrupted` | Cancelled via `turn/interrupt` |
| `failed` | Ended with `TurnError` (message + optional `codexErrorInfo`) |

**ThreadItem** is a tagged union (`type` on the wire). Variants include `userMessage`, `agentMessage`, `reasoning`, `commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, `imageView`, `imageGeneration`, `enteredReviewMode`, `exitedReviewMode`, `contextCompaction`, `hookPrompt`, and `plan` (experimental). Items are the unit of `item/started` / `item/completed` notifications and optional delta streams (`item/agentMessage/delta`, command output deltas, etc.).

```mermaid
classDiagram
    class Thread {
        +String id
        +String sessionId
        +bool ephemeral
        +ThreadStatus status
        +Option~PathBuf~ path
        +Vec~Turn~ turns
    }
    class Turn {
        +String id
        +TurnStatus status
        +Vec~ThreadItem~ items
        +TurnItemsView itemsView
    }
    class ThreadItem {
        <<union>>
        userMessage
        agentMessage
        commandExecution
        fileChange
        ...
    }
    Thread "1" --> "*" Turn : contains
    Turn "1" --> "*" ThreadItem : contains
```

## Loaded threads vs persisted rollouts

App-server keeps two related views of the same logical conversation:

```text
  Client RPC                    In-memory (app-server)              On disk
  -----------                   ----------------------              --------
  thread/start ──────────────► CodexThread + listener task
  thread/resume ─────────────► (loads rollout into CodexThread)
  thread/read  ──────────────► (optional) ───────────────────────► rollout JSONL
  thread/list  ──────────────────────────────────────────────────► sqlite index + rollout files
  thread/loaded/list ────────► ThreadManager (ids only)
```

**Persisted threads** materialize rollout files under the Codex home sessions directory. `thread/list` pages stored rollouts (cursor pagination, filters for `cwd`, `archived`, `searchTerm`, etc.). Each listed thread includes `status`, defaulting to `notLoaded` when that thread id is not currently loaded in memory.

**Loaded threads** are live `CodexThread` instances held by `ThreadManager`. `thread/loaded/list` returns only ids currently in memory. While loaded, the server runs a per-thread **listener task** that reads `conversation.next_event()`, translates core events in `bespoke_event_handling`, and emits v2 notifications to subscribed connections.

**Read without resume:** `thread/read` fetches metadata (and optional turn history from rollout) without attaching a listener or loading into `ThreadManager`. Returned `status` is typically `notLoaded` even when history is included.

**Resume / start / fork** load (or create) the in-memory thread, attach listeners, and auto-subscribe the calling connection to turn/item notifications.

<Note>
Ephemeral threads skip disk persistence in core session init: no `LiveThread`, no state DB, and `thread.path` stays `null`. They also reject `includeTurns` on `thread/read` and `thread/turns/list`.
</Note>

## Subscription model

Subscriptions are **per transport connection**, not global. `ThreadStateManager` tracks:

- `live_connections` — initialized connections eligible to subscribe
- `threads[threadId].connection_ids` — which connections receive that thread’s notifications
- `thread_ids_by_connection` — reverse index for cleanup on disconnect

**Auto-subscribe** happens when a connection starts, resumes, or forks a thread: `ensure_conversation_listener` registers the connection, starts (or reuses) the listener task, and routes outbound events through `ThreadScopedOutgoingMessageSender`, which filters notifications to `subscribed_connection_ids` only.

```mermaid
sequenceDiagram
    participant Client
    participant AppServer
    participant Listener
    participant CodexThread

    Client->>AppServer: thread/start (connection C)
    AppServer->>AppServer: try_ensure_connection_subscribed
    AppServer->>Listener: spawn listener task
    Client->>AppServer: turn/start
    CodexThread-->>Listener: next_event()
    Listener->>Client: item/started (only if C subscribed)
```

**`thread/unsubscribe`** removes the current connection from `connection_ids`. Response `status`:

| Status | Meaning |
|--------|---------|
| `unsubscribed` | Connection was subscribed and is now removed |
| `notSubscribed` | Connection was not subscribed |
| `notLoaded` | Thread is not loaded |

When the **last subscriber** drops off, the thread **stays loaded**. Unload runs only after **both** no subscribers **and** no `active` thread status for **30 minutes** (`THREAD_UNLOADING_DELAY` in `thread_lifecycle.rs`). Then the server shuts down `CodexThread`, emits `thread/closed`, and `thread/status/changed` → `notLoaded`.

Disconnecting a transport removes that connection from all threads; threads with zero subscribers enter the same idle-unload path.

<Tip>
`thread/unsubscribe` during an in-flight turn does not stop the turn; it only stops notifications to that connection. Integration tests confirm the thread remains in `thread/loaded/list` until the idle timeout.
</Tip>

## Thread status

`Thread.status` on the wire is a separate concern from `Turn.status`. It describes **whether the thread is loaded in app-server** and **what it is doing right now**, maintained by `ThreadWatchManager` from runtime facts (running turn, pending approvals, pending user input, system error).

```mermaid
stateDiagram-v2
    [*] --> notLoaded: thread unloaded / never tracked
    notLoaded --> idle: upsert_thread (loaded, idle)
    idle --> active: turn started OR pending approval/input
    active --> idle: turn completed / guards released
    active --> systemError: note_system_error
    systemError --> active: next turn started
    idle --> notLoaded: remove_thread after unload
    active --> notLoaded: remove_thread after unload
```

| `ThreadStatus` | When |
|----------------|------|
| `notLoaded` | Thread not in watch state (default for `thread/list` / `thread/read` of stored-only threads) |
| `idle` | Loaded, not running, no pending server requests |
| `active` | Turn running and/or `activeFlags` non-empty |
| `systemError` | Last turn ended in a system error state until the next turn starts |

**`activeFlags`** (only on `active`):

- `waitingOnApproval` — command/file/permissions approval server request outstanding
- `waitingOnUserInput` — `tool/requestUserInput` outstanding

`thread/status/changed` notifications fire on transitions after a thread is known to the client. `thread/start` / `thread/fork` / detached review threads include current `status` on `thread/started` instead of a separate initial status notification.

`resolve_thread_status` bridges a race: if a turn is in progress but watch state still says `idle` or `notLoaded`, clients see `active` with empty `activeFlags`.

## Ephemeral threads

Pass `ephemeral: true` on `thread/start` or `thread/fork` for a session that must not be materialized on disk.

| Behavior | Persisted thread | Ephemeral thread |
|----------|------------------|------------------|
| Rollout file | Created under sessions dir | None (`path: null`) |
| `thread/list` | Appears after persistence | Not listed from disk |
| `thread/read` + `includeTurns` | Supported from rollout | **Rejected** |
| `thread/turns/list` | Supported (experimental) | **Rejected** |
| Goals / some metadata RPCs | Full sqlite-backed features | Reduced (core rejects metadata updates) |

The response field `thread.ephemeral` reflects `config_snapshot.ephemeral` from the loaded `CodexThread`.

## Turn and item streaming path

After `turn/start`, the RPC response includes an initial `turn` (usually `inProgress`). Streaming proceeds asynchronously:

<Steps>
<Step title="Subscribe and listen">
Ensure the connection is subscribed (automatic on `thread/start` / `thread/resume` / `thread/fork`). Read notifications on the transport.
</Step>
<Step title="Turn boundaries">
`turn/started` when the core emits `TurnStarted`; `turn/completed` on `TurnComplete` or interrupt handling. `ThreadWatchManager` updates status in parallel.
</Step>
<Step title="Items">
`item/started` / `item/completed` (and deltas) are produced in `bespoke_event_handling` from core `EventMsg` values. `ThreadState` tracks the active turn via `ThreadHistoryBuilder` for snapshots and interrupt/rollback ordering.
</Step>
<Step title="Server requests mid-turn">
Approvals and user-input requests go only to subscribed connections on that thread, ordered through the listener command channel when resolved.
</Step>
</Steps>

`turn/steer` appends user input to an **already running** regular turn (returns active `turnId`). Review and manual compaction turns reject steer.

## Persistence and history APIs

| Method | Loads in memory? | History source |
|--------|------------------|----------------|
| `thread/start` | Yes (new) | Empty, then live events |
| `thread/resume` | Yes | Rollout + live events |
| `thread/fork` | Yes (new id) | Copied history; may interrupt mid-turn source |
| `thread/read` | No | Rollout when `includeTurns: true` |
| `thread/turns/list` | No | Rollout pagination (experimental) |
| `thread/rollback` | Yes | Truncates in-memory context + rollout marker |

Rollout items (`RolloutItem` in core) are converted to API `Turn` / `ThreadItem` via builders such as `build_turns_from_rollout_items` / `build_api_turns_from_rollout_items`. Token usage replay and resume redaction are handled in dedicated processors without changing the wire shapes.

## Implementation map

| Concern | Primary module |
|---------|----------------|
| Per-connection subscribe/unsubscribe | `src/thread_state.rs` (`ThreadStateManager`) |
| `ThreadStatus` + `thread/status/changed` | `src/thread_status.rs` (`ThreadWatchManager`) |
| Listener, idle unload, `thread/closed` | `src/request_processors/thread_lifecycle.rs` |
| Thread CRUD, list/read/resume | `src/request_processors/thread_processor.rs` |
| `turn/start`, steer, interrupt | `src/request_processors/turn_processor.rs` |
| Event → notification translation | `src/bespoke_event_handling.rs` |
| Wire types | `app-server-protocol/src/protocol/v2/thread_data.rs`, `thread.rs`, `item.rs`, `turn.rs` |

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
End-to-end `thread/start` → `turn/start` → `item/*` → `turn/completed`.
</Card>
<Card title="Connection lifecycle" href="/connection-lifecycle">
Initialize, subscribe/unsubscribe, and server-initiated requests.
</Card>
<Card title="Stream turns and events" href="/stream-turns-and-events">
Notification catalog, deltas, and opt-out.
</Card>
<Card title="Thread lifecycle examples" href="/thread-lifecycle-examples">
Copy-paste JSON-RPC sequences including idle unload.
</Card>
<Card title="RPC methods reference" href="/rpc-methods">
Full v2 method list with experimental markers.
</Card>
</CardGroup>
