A session is a conversation with an agent. The session object reports it in two parts: `conversation` says whether the session can take more messages, and `turn` says what is happening to the latest message. A turn is one message and the agent's answer to it. The message you sent is answered when its turn reaches a final status.

## Conversation

| Field | Meaning |
| --- | --- |
| `conversation.state` | `open` while the session can take messages; `closed` is permanent |
| `conversation.interactive` | Whether the session stays open after its first turn; set it when [starting a session](/docs/sessions#run-a-one-off-task), on an agent, or when [starting an agent session](/docs/agents#override-settings-for-a-session) |
| `conversation.warm` | Whether an agent is ready to answer the next message right away; when `false`, the next message first prepares an environment, which takes longer |
| `conversation.prompting` | Whether a message you send now would be accepted: `enabled`, and when it is `false`, `blocked_reason` and `detail` say why |

A session started with `interactive: false` closes when its turn ends. After that, start a new session. `warm` is a hint for what to show while a message is `queued` or `preparing`, never something to branch on. `prompting.detail` is a sentence you can show as is. `blocked_reason` takes these values.

| Reason | Meaning |
| --- | --- |
| `closed` | The conversation is closed |
| `mention_surface` | The session was started by a mention; reply on the surface `surface_name` names (GitHub, Slack, or Linear) |
| `non_interactive` | The session was started with `interactive: false` |
| `harness_single_turn` | The harness takes one message per session |
| `budget_exhausted` | The session spent its [budget](/docs/budgets) |

`POST /v1/sessions/{id}/messages` fails with `409` while `prompting.enabled` is `false`.

## Turn

<span id="statuses" />

| Status | Meaning |
| --- | --- |
| `queued` | The message was received and waits to start, behind another turn or until a session ends when your account has its [limit](/docs/organization#platform-allowances) of sessions alive, which `detail` then says |
| `preparing` | The environment for the message is being set up |
| `running` | The agent is working on the message |
| `completed` | The agent finished |
| `failed` | The turn ended with an error; `reason` and `detail` explain |
| `stopped` | Someone stopped the turn; `stopped.at` and `stopped.by` say when and who |
| `cancelled` | The turn was refused before the agent started; `reason` and `detail` explain |

`completed`, `failed`, `stopped`, and `cancelled` are final. A final turn ends that turn, not the conversation: an interactive session still takes follow-ups while `prompting.enabled` is `true`. The other turn fields are `id`, `index` (its position in the session, from 0), `created_at` (when the message was received), `started_at`, `ended_at`, `cost`, and `tokens`.

`reason` is set only on `failed` and `cancelled` turns:

| Reason | Meaning |
| --- | --- |
| `error` | The agent ended with an error |
| `tool_call_failed` | The agent process failed or stalled before finishing |
| `budget_hit` | The session [budget](/docs/budgets) or a spend limit was reached |
| `payment_required` | The account has no credit or a subscription problem |
| `blocked` | The account or user is blocked |
| `contact_email_required` | The account needs a contact email |
| `lifecycle_hook_failed` | An environment [hook](/docs/environments#setup-and-hooks) exited with a nonzero status |
| `missing_repo_access` | The GitHub installation can't access a repository the session needs |
| `missing_token_permissions` | The GitHub installation lacks a permission the session needs |
| `missing_environment_variables` | An environment variable has no value |
| `missing_github_integration` | The account has no GitHub installation |
| `agent_unavailable` | The saved agent was deleted or disabled |
| `upstream_unavailable` | The model provider was rate limited or down after retries, or the Claude or Codex subscription the session uses is unavailable |
| `unsupported_configuration` | The harness, model, and settings can't be combined |
| `execution_disabled` | Sessions from this source are disabled |
| `interrupted` | The turn lost its agent before answering; the message is queued again on a new turn |

`detail` is a sentence you can show as is.

## Wait for the answer

<span id="check-the-status" />

The message you sent is answered when the turn with its `turn_id` reaches a final status. `POST /v1/sessions` returns the session with the turn for its prompt in `turn`; `POST /v1/sessions/{id}/messages` returns the `message` and its `turn`. Poll [`GET /v1/sessions/{session_id}/turns/{id}`](/docs/api/sessions/get-sessions-session_id-turns-id) or watch the stream's `session` frames until `status` is final. Waiting for the conversation to close, or for it to go quiet, is wrong: an open conversation stays open between turns.

The SDK handle's `wait()` does this for you. It returns when the turn it waits on ends: the session's opening turn, then each turn a `send()` creates.

```python
session = handle.wait(timeout=900)
turn = session.turn
print(turn.status, turn.reason, turn.detail)
print(session.conversation.state, session.conversation.prompting.enabled)
```

```typescript
const session = await handle.wait({ timeoutMs: 900_000 });
const turn = session.turn;
console.log(turn?.status, turn?.reason, turn?.detail);
console.log(session.conversation.state, session.conversation.prompting.enabled);
```

`session.turn` is the turn in progress, or the latest one when none is running, and null until the session has a message. [`GET /v1/sessions/{id}/turns`](/docs/api/sessions/get-sessions-id-turns) lists every turn and message, oldest first.

## Follow the stream

```python
from ellipsis.frames import StreamFrame

def on_frame(frame: StreamFrame):
    if frame.type in ("snapshot", "session"):
        turn = frame.session.turn
        if turn is not None:
            print(turn.index, turn.status)
        print(frame.session.conversation.state)
    elif frame.type == "records_append":
        for record in frame.records:
            print(record.feed_seq, record.kind)
    elif frame.type == "error":
        print(frame.message)
    elif frame.type == "done":
        print("Conversation closed")
```

```typescript
import type { StreamFrame } from '@ellipsis-dev/sdk/stream';

function onFrame(frame: StreamFrame) {
  if (frame.type === 'snapshot' || frame.type === 'session') {
    const turn = frame.session.turn;
    if (turn) console.log(turn.index, turn.status);
    console.log(frame.session.conversation.state);
  } else if (frame.type === 'records_append') {
    for (const record of frame.records) {
      console.log(record.feed_seq, record.kind);
    }
  } else if (frame.type === 'error') {
    console.error(frame.message);
  } else if (frame.type === 'done') {
    console.log('Conversation closed');
  }
}
```

Pass this callback to the stream in the [Python](/docs/api/python-sdk#stream-a-session) or [TypeScript](/docs/api/typescript-sdk#stream-a-session) SDK. `snapshot` sends the current session, `session` replaces it whenever the turn, the conversation, or the cost changes, `records_append` adds saved records in `feed_seq` order, and `done` means the conversation closed. `delta` frames carry live partial output that isn't saved. A [`turn_ended`](/docs/api/session-events#turn_ended) record carries the same `status`, `reason`, and `detail` as the turn. Frames can repeat, so ignore duplicates and types you don't recognize. See [Session events](/docs/api/session-events) for every event.

A session keeps the Claude or Codex subscription it started with; connecting a different one affects only new sessions.
