# Thread lifecycle examples

> Copy-paste JSON-RPC sequences for `thread/start`, `thread/resume`, `thread/fork`, `thread/list` pagination, `turn/interrupt`, and `thread/unsubscribe` idle unload from README-backed tests.

- 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`
- `tests/suite/v2/thread_list.rs`
- `tests/suite/v2/thread_rollback.rs`
- `tests/suite/v2/thread_inject_items.rs`
- `src/request_processors/thread_processor.rs`
- `tests/common/test_app_server.rs`

---

---
title: "Thread lifecycle examples"
description: "Copy-paste JSON-RPC sequences for `thread/start`, `thread/resume`, `thread/fork`, `thread/list` pagination, `turn/interrupt`, and `thread/unsubscribe` idle unload from README-backed tests."
---

`codex app-server` v2 exposes thread lifecycle as JSON-RPC 2.0 requests on a newline-delimited transport (stdio JSONL by default). Every connection must complete `initialize` / `initialized` before `thread/*` or `turn/*` calls. Thread RPCs auto-subscribe the caller to turn and item notifications; `thread/unsubscribe` drops that subscription without immediately evicting in-memory state.

## Wire format

Each message is one JSON object per line. Field names are **camelCase** on the wire (`threadId`, `nextCursor`, `forkedFromId`). Request ids are client-chosen integers (or strings); responses echo the same `id`.

<Note>
Integration tests in `tests/common/test_app_server.rs` spawn `codex-app-server`, send requests with `send_request`, and read responses/notifications from stdout until a matching `id` or `method` arrives.
</Note>

## Connection bootstrap

Send once per transport connection, then keep reading the stream for interleaved responses and notifications.

<Steps>
<Step title="Initialize">
<RequestExample>

```json
{"method":"initialize","id":0,"params":{"clientInfo":{"name":"my_client","title":"My Client","version":"0.1.0"}}}
```

</RequestExample>
<ResponseExample>

```json
{"id":0,"result":{"userAgent":"…","codexHome":"/path/to/.codex","platformFamily":"…","platformOs":"…"}}
```

</ResponseExample>
</Step>
<Step title="Acknowledge">
<RequestExample>

```json
{"method":"initialized","params":{}}
```

</RequestExample>
</Step>
</Steps>

<Warning>
Any RPC before `initialize` completes returns `"Not initialized"`. A second `initialize` on the same connection returns `"Already initialized"`.
</Warning>

## Lifecycle map

```mermaid
sequenceDiagram
  participant Client
  participant AppServer
  participant Store as Rollout store

  Client->>AppServer: initialize
  AppServer-->>Client: result
  Client->>AppServer: initialized (notification)

  alt New conversation
    Client->>AppServer: thread/start
    AppServer-->>Client: result.thread
    AppServer-->>Client: thread/started
  else Continue stored session
    Client->>AppServer: thread/resume
    AppServer-->>Client: result.thread
    AppServer-->>Client: thread/started (optional tokenUsage)
  else Branch history
    Client->>AppServer: thread/fork
    AppServer-->>Client: result.thread
    AppServer-->>Client: thread/started
  end

  Client->>AppServer: turn/start
  AppServer-->>Client: result.turn
  AppServer-->>Client: turn/started, item/*, turn/completed

  opt Cancel in-flight work
    Client->>AppServer: turn/interrupt
    AppServer-->>Client: result {}
    AppServer-->>Client: turn/completed (interrupted)
  end

  Client->>AppServer: thread/unsubscribe
  AppServer-->>Client: result.status
  Note over AppServer: Idle unload after 30m with no subscribers and no active turn
  AppServer-->>Client: thread/status/changed (notLoaded)
  AppServer-->>Client: thread/closed

  Client->>AppServer: thread/list
  AppServer-->>Client: data, nextCursor
  Store-->>AppServer: persisted rollouts
```

## `thread/start`

Creates a new thread, returns the thread object, emits `thread/started`, and auto-subscribes this connection to that thread’s turn/item stream. `thread/start` does **not** emit an initial `thread/status/changed`; status is carried on `thread/started`.

<ParamField body="model" type="string">
Optional model override; otherwise config defaults apply.
</ParamField>
<ParamField body="cwd" type="string">
Workspace directory; with `workspace-write` or full-access sandbox, marks the project trusted in `config.toml`.
</ParamField>
<ParamField body="ephemeral" type="boolean">
When `true`, thread stays in-memory only and `thread.path` is `null`.
</ParamField>
<ParamField body="threadSource" type="string">
Optional analytics classification (for example `"user"`).
</ParamField>

<RequestExample>

```json
{"method":"thread/start","id":10,"params":{"model":"gpt-5.1-codex","cwd":"/Users/me/project","approvalPolicy":"never","sandbox":"workspaceWrite","personality":"friendly","threadSource":"user"}}
```

</RequestExample>
<ResponseExample>

```json
{"id":10,"result":{"thread":{"id":"thr_abc","sessionId":"thr_abc","preview":"","name":null,"ephemeral":false,"modelProvider":"openai","createdAt":1730910000,"status":{"type":"idle"},"threadSource":"user"},"model":"gpt-5.1-codex","modelProvider":"openai","cwd":"/Users/me/project","sandbox":"…"}}
{"method":"thread/started","params":{"thread":{"id":"thr_abc","sessionId":"thr_abc","preview":"","name":null,"ephemeral":false,"status":{"type":"idle"},"threadSource":"user","turns":[]}}}
```

</ResponseExample>

<Tip>
Wire contract from `tests/suite/v2/thread_start.rs`: `sessionId` lives on `result.thread`, not as a top-level field; unset titles serialize as `"name": null`; new persistent threads use `"ephemeral": false`.
</Tip>

## `thread/resume`

Reopens a stored thread by id so later `turn/start` calls append to it. Response shape matches `thread/start`. When persisted token usage exists, expect `thread/tokenUsage/updated` after the response.

<RequestExample>

```json
{"method":"thread/resume","id":11,"params":{"threadId":"thr_123","personality":"friendly"}}
```

</RequestExample>
<ResponseExample>

```json
{"id":11,"result":{"thread":{"id":"thr_123","preview":"…","status":{"type":"idle"},"turns":[…]},"model":"…","modelProvider":"openai","cwd":"/abs/path"}}
{"method":"thread/started","params":{"thread":{"id":"thr_123","status":{"type":"idle"},"turns":[]}}}
```

</ResponseExample>

### Resume without inline turns (experimental)

Pass `excludeTurns: true` to get metadata only, then page with `thread/turns/list`. Token-usage replay is skipped in this mode.

<CodeGroup>
```json title="Metadata-only resume"
{"method":"thread/resume","id":12,"params":{"threadId":"thr_123","excludeTurns":true}}
```
```json title="Response"
{"id":12,"result":{"thread":{"id":"thr_123","turns":[]}}}
```
</CodeGroup>

### Resume with bundled turns page (experimental)

`initialTurnsPage` accepts the same paging controls as `thread/turns/list` (`limit`, `sortDirection`, `itemsView`).

<RequestExample>

```json
{"method":"thread/resume","id":13,"params":{"threadId":"thr_123","excludeTurns":true,"initialTurnsPage":{"limit":20,"sortDirection":"desc","itemsView":"summary"}}}
```

</RequestExample>
<ResponseExample>

```json
{"id":13,"result":{"thread":{"id":"thr_123","turns":[]},"initialTurnsPage":{"data":[],"nextCursor":null,"backwardsCursor":null}}}
```

</ResponseExample>

## `thread/fork`

Copies stored history into a **new** thread id, emits `thread/started`, and auto-subscribes. If the source thread is mid-turn, the fork records an interruption marker (same semantics as `turn/interrupt`) instead of copying an unmarked partial suffix.

<ParamField body="threadId" type="string" required>
Source thread id (preferred identifier).
</ParamField>
<ParamField body="ephemeral" type="boolean">
In-memory fork; does not appear in `thread/list`.
</ParamField>
<ParamField body="excludeTurns" type="boolean">
Experimental; omit turn bodies from `result.thread.turns` and page via `thread/turns/list`.
</ParamField>

<RequestExample>

```json
{"method":"thread/fork","id":14,"params":{"threadId":"thr_123","threadSource":"user"}}
```

</RequestExample>
<ResponseExample>

```json
{"id":14,"result":{"thread":{"id":"thr_456","sessionId":"thr_456","forkedFromId":"thr_123","preview":"Saved user message","status":{"type":"idle"},"turns":[{"status":"interrupted","items":[{"type":"userMessage","content":[{"type":"text","text":"Saved user message"}]}]}]}}}
{"method":"thread/started","params":{"thread":{"id":"thr_456","sessionId":"thr_456","forkedFromId":"thr_123","name":null,"turns":[]}}}
```

</ResponseExample>

<Check>
`tests/suite/v2/thread_fork.rs` verifies: new id ≠ source id; `sessionId` equals the new root id; `forkedFromId` points at the source; response may include copied turns but `thread/started` always sends `"turns": []`; original rollout files are not mutated.
</Check>

Ephemeral fork:

```json
{"method":"thread/fork","id":15,"params":{"threadId":"thr_123","ephemeral":true}}
{"id":15,"result":{"thread":{"id":"thr_eph","ephemeral":true,"path":null}}}
```

## `thread/list` pagination

Lists persisted rollouts for history UIs. Default sort is `created_at` descending (newest first). Listed threads that are not loaded report `"status": {"type": "notLoaded"}`.

| Param | Role |
| --- | --- |
| `cursor` | Opaque token from a prior page; omit on first request |
| `limit` | Page size; server picks a default when omitted |
| `sortKey` | `created_at` (default) or `updated_at` |
| `sortDirection` | `desc` (default) or `asc` |
| `modelProviders` | Filter by provider; `[]` means all providers |
| `sourceKinds` | Filter sources; omit/`[]` → interactive (`cli`, `vscode`) |
| `archived` | `true` → archived only; `false`/`null` → non-archived (default) |
| `cwd` | Exact cwd match (string or string array) |
| `searchTerm` | Case-sensitive substring on extracted title |
| `useStateDbOnly` | `true` skips JSONL scan/repair |

<RequestExample>

```json
{"method":"thread/list","id":20,"params":{"limit":2,"modelProviders":["mock_provider"]}}
```

</RequestExample>
<ResponseExample>

```json
{"id":20,"result":{"data":[{"id":"thr_a","preview":"Hello","modelProvider":"mock_provider","createdAt":1730831111,"updatedAt":1730831111,"status":{"type":"notLoaded"}},{"id":"thr_b","preview":"Hello","modelProvider":"mock_provider","createdAt":1730750000,"updatedAt":1730750000,"status":{"type":"notLoaded"}}],"nextCursor":"opaque-token","backwardsCursor":"opaque-token"}}
```

</RequestExample>

Second page (from `tests/suite/v2/thread_list.rs` — three rollouts, `limit: 2`):

<RequestExample>

```json
{"method":"thread/list","id":21,"params":{"cursor":"opaque-token","limit":2,"modelProviders":["mock_provider"]}}
```

</RequestExample>
<ResponseExample>

```json
{"id":21,"result":{"data":[{"id":"thr_c","preview":"Hello","status":{"type":"notLoaded"}}],"nextCursor":null,"backwardsCursor":"opaque-token"}}
```

</ResponseExample>

<Info>
`nextCursor: null` means the final page in the current sort direction. Use `backwardsCursor` with the opposite `sortDirection` to page backward without skipping same-second updates.
</Info>

Filtered listing example from the README:

```json
{"method":"thread/list","id":22,"params":{"limit":25,"cwd":["/Users/me/project","/Users/me/project-worktree"],"sortKey":"created_at"}}
```

## `turn/interrupt`

Cancels an in-flight turn by `(threadId, turnId)`. Success is an empty object `{}`; completion is signaled by `turn/completed` with `"status": "interrupted"`. Does not kill background terminals.

<RequestExample>

```json
{"method":"turn/start","id":30,"params":{"threadId":"thr_abc","input":[{"type":"text","text":"run sleep"}]}}
{"id":30,"result":{"turn":{"id":"turn_xyz","status":"inProgress"}}}
{"method":"turn/interrupt","id":31,"params":{"threadId":"thr_abc","turnId":"turn_xyz"}}
```

</RequestExample>
<ResponseExample>

```json
{"id":31,"result":{}}
{"method":"turn/completed","params":{"threadId":"thr_abc","turn":{"id":"turn_xyz","status":"interrupted"}}}
```

</ResponseExample>

<Warning>
Interrupting a turn that already completed returns JSON-RPC error code `-32600` (`invalid request`). `tests/suite/v2/turn_interrupt.rs` asserts this for a finished assistant turn.
</Warning>

If a command approval is pending, interrupt also emits `serverRequest/resolved` before `turn/completed`.

## `thread/unsubscribe` and idle unload

Removes **this connection’s** subscription to turn/item events. The thread can remain loaded in memory until it has had **no subscribers and no active turn activity** for **30 minutes** (`THREAD_UNLOADING_DELAY` in `src/request_processors/thread_lifecycle.rs`), then the server emits `thread/closed` and `thread/status/changed` → `notLoaded`.

<ResponseField name="status" type="ThreadUnsubscribeStatus">
One of `unsubscribed`, `notSubscribed`, or `notLoaded`.
</ResponseField>

| Status | Meaning |
| --- | --- |
| `unsubscribed` | Connection was subscribed; now removed |
| `notSubscribed` | Connection was not subscribed to that thread |
| `notLoaded` | Thread is not loaded (teardown may already have run) |

<RequestExample>

```json
{"method":"thread/unsubscribe","id":40,"params":{"threadId":"thr_abc"}}
```

</RequestExample>
<ResponseExample>

```json
{"id":40,"result":{"status":"unsubscribed"}}
```

</ResponseExample>

<Note>
`tests/suite/v2/thread_unsubscribe.rs`: immediately after unsubscribe, `thread/closed` does **not** arrive within 250ms; `thread/loaded/list` still includes the thread id. A second unsubscribe on the same connection returns `"status":"notSubscribed"`. Unsubscribing during an in-flight turn does not stop the turn.
</Note>

After the idle window:

```json
{"method":"thread/status/changed","params":{"threadId":"thr_abc","status":{"type":"notLoaded"}}}
{"method":"thread/closed","params":{"threadId":"thr_abc"}}
```

Resuming before unload preserves cached status (for example `systemError` after a failed turn) — `thread/resume` returns the same `thread.status` without requiring a new failure.

## Verification checklist

| Goal | RPC sequence | Pass signal |
| --- | --- | --- |
| New thread | `thread/start` → read response + `thread/started` | `result.thread.id` set; `thread/started` has matching id; no prior `thread/status/changed` for that id |
| Continue session | `thread/resume` with stored `threadId` | Same as start; optional `thread/tokenUsage/updated` |
| Branch | `thread/fork` | New id; `forkedFromId` set; `thread/started.turns` is `[]` |
| History UI | `thread/list` with `limit` + `cursor` | First page `nextCursor` non-null when more rows exist; last page `nextCursor: null` |
| Cancel generation | `turn/interrupt` during active turn | `result: {}` then `turn/completed` with `interrupted` |
| Drop live stream | `thread/unsubscribe` | `status: unsubscribed`; no immediate `thread/closed`; thread still in `thread/loaded/list` |

## Related pages

<CardGroup>
<Card title="Connection lifecycle" href="/connection-lifecycle">
Per-connection handshake, subscribe/unsubscribe semantics, and server-initiated requests.
</Card>
<Card title="Threads, turns, and items" href="/threads-turns-items">
Core primitives, subscription model, thread status states, and ephemeral threads.
</Card>
<Card title="Quickstart" href="/quickstart">
Shortest path from `initialize` through `turn/completed`.
</Card>
<Card title="Stream turns and events" href="/stream-turns-and-events">
Notification methods and delta events after `turn/start`.
</Card>
<Card title="RPC methods reference" href="/rpc-methods">
Full v2 method catalog with stable vs experimental markers.
</Card>
<Card title="Development and testing" href="/development-and-testing">
Running `just test -p codex-app-server` and the `test_app_server` harness.
</Card>
</CardGroup>
