Sessions
A simplified 1 user ↔ 1 agent conversational interface, owned by an agent.
Overview
Sessions hide the underlying Conversation and generation plumbing. The Actor 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) and Step 6 (Send messages and receive replies).
Sessions are a top-level resource at /sessions. Each session belongs to an Agent — set agent_id on create, and filter by it with GET /sessions?agent_id=. Each session exposes its conversation_id as an escape hatch to the full Conversations API; list a session's messages via GET /conversations/:conversation_id/messages (this is governed by conversations:GetConversation, not the agents:* session actions).
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Chat with an LLM - Step 5 (Create a session)
- Chat with an LLM - Step 6 (Send messages and receive replies)
- Debug Session, Generation, and 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 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 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 bodydocument_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.
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 are created separately and are never auto-created here. This matters beyond naming: end-user attribution on the resulting usage events is derived from the session's actor, so a session without one produces generations that match no actor-scoped quota. 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 for what happens when a session is deleted.
Deletion
DELETE .../sessions/:session_id removes the session row and its underlying Conversation row in the same transaction. Deleting the conversation cascades at the database level to every message in it.
What deletion does not remove:
- The session's actor. The Actor referenced by
actor_idis 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 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 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}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 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.
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 on the fork |
Fork by reference, not by copy. The fork gets its own conversation whose messages point at the same Document 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 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:
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}.
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 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:
{
"message": "Hello",
"idempotency_key": "wamid.HBgLNTUxMTk4..."
}
- First call — message is saved and generation is triggered if
auto_generateis on. Returns201 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 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 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 for the platform-wide wait contract. The default 202 body:
{ "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}/messagesreturns the conversation timeline — see Debug Session, Generation, and Trace History - Step 4 (Retrieve the full session message timeline).GET /api/v1/traces/{trace_id}returns the execution trace.GET /api/v1/traces/{trace_id}/treereturns the full trace tree for nested agent calls.
See Traces 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
- CLI
- SDK
- curl
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
import { SoatClient } from '@soat/sdk';
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 },
});
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>"
List sessions
Filter by agent, actor, or status.
- CLI
- SDK
- curl
soat list-sessions --agent-id agent_01 --status open
const { data: sessions } = await soat.sessions.listSessions({
query: { agent_id: 'agent_01', status: 'open' },
});
curl "https://api.example.com/api/v1/sessions?agent_id=agent_01&status=open" \
-H "Authorization: Bearer <token>"