# Sessions

> A simplified one-user-to-one-agent conversational interface owned by an agent.

# Sessions

A simplified 1 user ↔ 1 agent conversational interface, owned by an agent.

## Overview

Sessions hide the underlying [Conversation](./conversations.md) and generation plumbing. The [Actor](./actors.md) is not hidden and not created for you — link one with `actor_id` when the session represents a specific end user. By default, interacting with an agent requires three API calls: create a session, save a user message, and trigger generation. When `auto_generate` is enabled, the message and generation collapse into a single call. Walk through it end to end in [Chat with an LLM - Step 5 (Create a session)](/docs/tutorials/chat-with-llm#step-5--create-a-session) and [Step 6 (Send messages and receive replies)](/docs/tutorials/chat-with-llm#step-6--send-messages-and-receive-replies).

Sessions are a top-level resource at `/sessions`. Each session belongs to an [Agent](./agents.md) — set `agent_id` on create, and filter by it with [`GET /sessions?agent_id=`](/docs/api/sessions/list-sessions). Each session exposes its `conversation_id` as an escape hatch to the full [Conversations](./conversations.md) API; list a session's messages via [`GET /conversations/:conversation_id/messages`](/docs/api/conversations/list-conversation-messages) (this is governed by `conversations:GetConversation`, not the `agents:*` session actions).

> See the [Permissions Reference](../permissions.md) for the IAM action strings for this module.

## Related Tutorials

- [Chat with an LLM - Step 5 (Create a session)](/docs/tutorials/chat-with-llm#step-5--create-a-session)
- [Chat with an LLM - Step 6 (Send messages and receive replies)](/docs/tutorials/chat-with-llm#step-6--send-messages-and-receive-replies)
- [Debug Session, Generation, and Trace History - Step 4 (Retrieve the full session message timeline)](/docs/tutorials/debug-session-generation-trace-history#step-4---retrieve-the-full-session-message-timeline)

## Data Model

### Session

| Field                    | Type            | Description                                                                                                      |
| ------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------- |
| `id`                     | string          | Public identifier prefixed with `sess_`                                                                          |
| `agent_id`               | string          | Public ID of the agent this session belongs to                                                                   |
| `conversation_id`        | string          | Public ID of the underlying conversation                                                                         |
| `status`                 | string          | `open` (default), `closed`, or `expired`                                                                         |
| `name`                   | string          | Optional display name                                                                                            |
| `actor_id`               | string \| null  | Optional public ID of the [Actor](./actors.md) linked on create (`actor_` prefix); `null` when none was supplied |
| `tags`                   | object          | Free-form key-value metadata                                                                                     |
| `auto_generate`          | boolean         | When `true`, saving a message automatically triggers LLM generation (default: `false`)                           |
| `message_delay_seconds`  | integer \| null | Debounce delay in seconds before the LLM is called after a user message. `null` means no delay (default).        |
| `inactivity_ttl_seconds` | integer         | Seconds of inactivity before the session expires. `0` means never expires (default: `0`)                         |
| `last_activity_at`       | string \| null  | ISO 8601 timestamp of the last user message; `null` until the first message is added                             |
| `forked_from_session_id` | string \| null  | Public ID of the session this one was [forked](#forking) from; `null` when it is not a fork, or once the parent is deleted |
| `forked_from_position`   | integer \| null | Parent conversation `position` this session branched after; `null` when it is not a fork or was forked at the tip |
| `created_at`             | string          | ISO 8601 creation timestamp                                                                                      |
| `updated_at`             | string          | ISO 8601 last-updated timestamp                                                                                  |

### Message (within a session)

| Field        | Type   | Description                       |
| ------------ | ------ | --------------------------------- |
| `role`       | string | `user` or `assistant`             |
| `content`    | string | Message text                      |
| `model`      | string | Model used for assistant messages |
| `created_at` | string | ISO 8601 timestamp                |

When creating a session message (`POST .../messages`), send exactly one of:

- `message`: raw text body
- `document_id`: public ID of an existing document (its content is used as the message text)

An optional `idempotency_key` string can be included with either variant — see [Idempotency](#idempotency).

## Key Concepts

### How Sessions Relate to Other Concepts

| Concept           | Relationship                                                                        |
| ----------------- | ----------------------------------------------------------------------------------- |
| **Chats**         | Raw LLM completions — no agents, no tools, caller manages history                   |
| **Sessions**      | 1 user ↔ 1 agent — full tool support, automatic history, owned by an agent          |
| **Conversations** | Multi-party dialogue engine — powers sessions internally, available as escape hatch |

### The Session's End User (Actor)

A session has an end user only when `actor_id` is supplied on create. [Actors](./actors.md) are created separately and are never auto-created here. This matters beyond naming: end-user attribution on the resulting [usage](./usage.md#end-user-attribution) events is derived from the session's actor, so a session without one produces generations that match no `actor`-scoped [quota](./quotas.md#actor-scope). Attach an actor before relying on a per-user spend cap.

### Lifecycle

A session starts in `open` status. It can be updated to `closed` when the interaction is complete. If `inactivity_ttl_seconds` is configured, the status transitions to `expired` lazily when the session is next fetched or listed after the TTL elapses. See [Deletion](#deletion) for what happens when a session is deleted.

### Deletion

`DELETE .../sessions/:session_id` removes the session row and its underlying [Conversation](./conversations.md) row in the same transaction. Deleting the conversation cascades at the database level to every [message](#message-within-a-session) in it.

What deletion does **not** remove:

- **The session's actor.** The [Actor](./actors.md) referenced by `actor_id` is left untouched and can still be looked up or reused by other sessions.
- **Documents backing message content.** Each message's content is stored in a [Document](./documents.md) row; deleting the session does not delete these documents (or their underlying files), so they remain in place after the session and its messages are gone.
- **Generations and traces.** A session's [generations and traces](./traces.md#debugging-joins-trace-generation-session) are not linked to the session or conversation record, so they are unaffected by session deletion and remain queryable via [`GET /api/v1/traces/{trace_id}`](/docs/api/traces/get-trace) after the session no longer exists.

Delete these resources explicitly beforehand if you need a full cleanup.

### Forking

[`POST /api/v1/sessions/{session_id}/fork`](/docs/api/sessions/fork-session) branches a new session from a point in an existing one: same context, different continuation. It answers "what if" — a support agent gave a bad answer at message 7, and you want to try a stricter prompt or a different agent version against *that exact* context without replaying the conversation by hand.

```bash
curl -X POST "$SOAT_URL/api/v1/sessions/$SESSION_ID/fork" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
        "fork_at_position": 7,
        "agent_id": "agent_V1StGXR8Z5jdHi6B",
        "name": "retry with stricter system prompt",
        "tags": { "experiment": "prompt-v2" }
      }'
```

| Field              | Default            | Meaning                                                                     |
| ------------------ | ------------------ | --------------------------------------------------------------------------- |
| `fork_at_position` | branch at the tip  | Parent `position` to branch **after**; positions `0..N` are carried over     |
| `agent_id`         | the parent's agent | The agent the fork runs against. Must be in the same project                 |
| `name`, `tags`     | —                  | Set on the new session                                                       |
| `tool_context`     | inherited          | Overrides the parent's [tool context](#tool-context) on the fork             |

**Fork by reference, not by copy.** The fork gets its own [conversation](./conversations.md) whose messages point at the **same [Document](./documents.md) rows** as the parent — only the ordering is duplicated. There is one stored copy of the content, so a retention purge erases it from parent and fork together, and the branch cannot drift from what actually happened.

**Replay, never re-invoke.** Recorded tool calls and their results ride along on the copied messages and are replayed as model input on the fork's next turn. Forking never calls a tool, so exploring a "what if" cannot send an email or charge a card a second time. The trade-off is that a forked turn sees the tool data **as it was**, not as it is now — right for comparison, wrong for "resume this session for real".

**The fork is inert.** `auto_generate` is `false` and no generation is triggered; drive the branch with the normal `POST .../messages` and `POST .../generate` endpoints. It also starts without an actor, because [single session per actor](#single-session-per-actor) allows one open session per (agent, actor) pair and inheriting the parent's actor would make forking impossible for exactly those agents.

Lineage reads back on the session itself (`forked_from_session_id`, `forked_from_position`) and from the parent:

```bash
curl "$SOAT_URL/api/v1/sessions/$SESSION_ID/forks" -H "Authorization: Bearer $TOKEN"
```

That walks **one level**. Forking a fork is allowed and unbounded; each fork is listed under its own parent. Deleting a parent does not delete its forks — they keep their history and their `forked_from_session_id` becomes `null`.

Forking requires both `agents:GetSession` and `agents:CreateSession`: it reads a session's full history and creates a new session, and neither permission alone should imply the other.

### Auto-Generate

When `auto_generate` is `true`, `POST .../messages` saves the user message **and** automatically triggers LLM generation in the same request. The response body contains the assistant reply instead of just the saved user message.

This collapses the three-call flow into two calls: create a session, then send messages. `auto_generate` defaults to `false` and can be set at creation or toggled at any time via [`PATCH /sessions/{session_id}`](/docs/api/sessions/update-session).

The explicit `POST .../generate` endpoint continues to work regardless of this setting. With `auto_generate` enabled, `POST .../messages` returns as soon as the message is saved and the triggered generation runs in the background.

### Message Delay (Debounce)

When `message_delay_seconds` is set, `POST .../messages` does **not** trigger LLM generation immediately. A timer starts and resets with each new message. The LLM is only called after the configured delay elapses with no new messages.

With `message_delay_seconds: 3`, three rapid messages ("What's the" / "weather in" / "Paris?") each reset the timer; after 3 seconds of silence the LLM is called once with all three messages in context.

`POST .../messages` always returns immediately with the saved user message, regardless of the delay setting. Generation fires asynchronously after the delay elapses.

`message_delay_seconds` has no effect when `auto_generate` is `false` or when a generation is already in progress.

### Single Session Per Actor

When the parent agent has `single_session_per_actor: true`, creating a session with an `actor_id` returns `409 Conflict` if an open session for that actor already exists. The error body includes `meta.session_id` with the existing session's ID. See [Single Session Per Actor](./agents.md#single-session-per-actor) on the Agents module.

### Idempotency

Channels like WhatsApp use at-least-once webhook delivery — the same inbound message may arrive multiple times. Pass `idempotency_key` in the `POST .../messages` body to deduplicate:

```json
{
  "message": "Hello",
  "idempotency_key": "wamid.HBgLNTUxMTk4..."
}
```

- **First call** — message is saved and generation is triggered if `auto_generate` is on. Returns `201 Created`.
- **Subsequent calls with the same key** — returns the original message with `200 OK`. No new message or generation is created.

The key is scoped to the session.

### Inactivity TTL

Sessions can expire automatically after a period of inactivity using `inactivity_ttl_seconds`.

- **`0` (default)** — the session never expires.
- **Positive integer** — the session expires if no user message has been added for that many seconds since `last_activity_at`.

When a session exceeds its TTL, its `status` is lazily updated to `expired` the next time it is fetched or listed. Once expired, `POST .../generate` returns `410 Gone` with error code `SESSION_EXPIRED` — open a fresh session to continue.

The TTL can be updated at any time via `PATCH .../sessions/{session_id}` — the inactivity clock continues from the last `last_activity_at` timestamp, so changing the TTL takes effect on the next fetch.

### Tool Context

Sessions support the same `tool_context` mechanism as direct agent generations — see the [Tool Context reference](../advanced/tool-context.md) for the key→header rule, validation and security notes.

When a generation is triggered through a session, the server automatically injects the following keys into `tool_context`:

| Injected key      | Forwarded header                 | Value                                                  |
| ----------------- | -------------------------------- | ------------------------------------------------------ |
| `actor_id`         | `X-Soat-Context-actor_id`         | Public ID of the session's actor; omitted if not set   |
| `actor_external_id` | `X-Soat-Context-actor_external_id` | External ID of the session's actor; omitted if not set |
| `session_id`       | `X-Soat-Context-session_id`       | Public ID of the session; always present               |

These three keys are always taken from the session and actor, regardless of what the caller supplies — a caller-provided `actor_id`, `actor_external_id` or `session_id` in either the session's stored `tool_context` or a per-request `tool_context` is ignored in favor of the auto-populated value. Any other key a caller sets in `tool_context` is unaffected and still wins in the usual way (a per-request value overrides the session's stored value).

Note that `actor_external_id` carries the actor's `external_id` to every `http` and `mcp` tool the agent calls; see [Actors](./actors.md#external_id-and-idempotent-creation) if that value holds PII.

### Background Generation

`POST .../generate` runs in the background by default and returns immediately with `202 Accepted`. Pass `?wait=true` to block until the generation settles and receive the assistant reply in the response. See [Synchronous & Asynchronous Execution](../advanced/sync-and-async.md) for the platform-wide `wait` contract. The default `202` body:

```json
{ "status": "accepted", "session_id": "sess_..." }
```

When a new generation request arrives while a previous one is still in-flight, the server **cancels the previous generation** and starts a fresh one so the model always sees the complete, up-to-date message history.

### Debugging (Session, Generation, Trace)

Each call to `POST .../generate` returns `generation_id` and `trace_id`. Store these alongside `session_id` for debugging:

- `GET .../sessions/{session_id}/messages` returns the conversation timeline — see [Debug Session, Generation, and Trace History - Step 4 (Retrieve the full session message timeline)](/docs/tutorials/debug-session-generation-trace-history#step-4---retrieve-the-full-session-message-timeline).
- [`GET /api/v1/traces/{trace_id}`](/docs/api/traces/get-trace) returns the execution trace.
- [`GET /api/v1/traces/{trace_id}/tree`](/docs/api/traces/get-trace-tree) returns the full trace tree for nested agent calls.

See [Traces](./traces.md#debugging-joins-trace-generation-session) for the full correlation strategy.

### Webhook Events

The following events are dispatched to project webhooks as sessions change state:

| Event type                            | Trigger                                                |
| ------------------------------------- | ------------------------------------------------------ |
| `sessions.created`                    | A new session is created                               |
| `sessions.updated`                    | A session's `name`, `status`, or `tags` are changed    |
| `sessions.deleted`                    | A session is deleted                                   |
| `sessions.generation.completed`       | LLM generation finished successfully                   |
| `sessions.generation.requires_action` | LLM returned a client-tool call requiring tool outputs |
| `sessions.generation.started`         | LLM generation has started for a session               |

All events include `session_id`. Generation events additionally include `generation_id` and `trace_id`. Permissions are namespaced under `agents:` since each session belongs to an agent.

## Examples

### Basic session flow

<Tabs groupId="client">
<TabItem value="cli" label="CLI" default>

```bash
soat create-session --agent-id agent_01 --name "My Session"
soat add-session-message --session-id sess_01 --message "Hello!"
soat generate-session-response --wait true --session-id sess_01
```

</TabItem>
<TabItem value="sdk" label="SDK">

```ts

const soat = new SoatClient({
  baseUrl: 'https://api.example.com',
  token: 'sk_...',
});

const { data: session } = await soat.sessions.createSession({
  body: { agent_id: 'agent_01', name: 'My Session' },
});

await soat.sessions.addSessionMessage({
  path: { session_id: session.id },
  body: { message: 'Hello!' },
});

const { data: reply } = await soat.sessions.generateSessionResponse({
  path: { session_id: session.id },
  query: { wait: true },
});
```

</TabItem>
<TabItem value="curl" label="curl">

```bash
curl -X POST https://api.example.com/api/v1/sessions \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "agent_01", "name": "My Session"}'

curl -X POST https://api.example.com/api/v1/sessions/sess_01/messages \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello!"}'

curl -X POST https://api.example.com/api/v1/sessions/sess_01/generate?wait=true \
  -H "Authorization: Bearer <token>"
```

</TabItem>
</Tabs>

### List sessions

Filter by agent, actor, or status.

<Tabs groupId="client">
<TabItem value="cli" label="CLI" default>

```bash
soat list-sessions --agent-id agent_01 --status open
```

</TabItem>
<TabItem value="sdk" label="SDK">

```ts
const { data: sessions } = await soat.sessions.listSessions({
  query: { agent_id: 'agent_01', status: 'open' },
});
```

</TabItem>
<TabItem value="curl" label="curl">

```bash
curl "https://api.example.com/api/v1/sessions?agent_id=agent_01&status=open" \
  -H "Authorization: Bearer <token>"
```

</TabItem>
</Tabs>
