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). An interaction takes three calls (create a session, save a user message, trigger generation), or two with auto_generate. See 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 belongs to an Agent: set agent_id on create (an agent outside the projects the caller reaches is 404, the same as one that does not exist) 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 (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; kept after a shared agent is deleted by its owner |
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) |
max_context_messages | integer | null | Most recent messages sent to the model per generation, over the agent's own; null defers to the agent (default). See Context window |
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 |
usage | object | What the session's generations cost — cost_usd and token counts. Single-session read only; see Session cost |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Message (within a session)
A session's messages are its conversation's Conversation Messages, listed by GET /api/v1/conversations/{conversation_id}/messages.
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 accompany either variant — see Idempotency.
Generated turn
What POST /api/v1/sessions/{session_id}/generate with ?wait=true, an auto-generating POST /api/v1/sessions/{session_id}/messages and POST /api/v1/sessions/{session_id}/tool-outputs answer. It is the body conversation generate answers.
A completed turn is stored right after the last message it read, so a message sent while it ran or waited on tool-outputs stays after it, and it emits conversation_messages.generated whichever of the three routes, or the agent's POST /api/v1/agents/{agent_id}/generate/{generation_id}/tool-outputs, completed it.
tool-outputs takes only a generation_id that ran in the session's conversation. Any other, such as a bare agent generation or another session's, is 404 GENERATION_NOT_FOUND before any output is submitted, so that generation stays paused.
| Field | Type | Description |
|---|---|---|
status | string | completed or requires_action |
content | string | The generated text. completed only |
message | object | The stored assistant Conversation Message, as the conversation lists it; its document_id addresses the reply. completed only |
model | string | Model used. completed only |
generation_id | string | The generation that produced the turn |
trace_id | string | The generation's trace |
required_action | object | Client tool calls to resolve through tool-outputs. requires_action only: nothing is stored yet, so there is no message |
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 |
Tags
Key-value string pairs, set on POST /api/v1/sessions and managed via the tag sub-endpoints. GET /api/v1/sessions filters by pair with ?tags=key:value (repeatable, all must match). Policy conditions read the same pairs through soat:ResourceTag/<key>; pass ?project_id= for a conditioned policy to narrow the listing. See IAM — Tags.
The Session's End User (Actor)
A session has an end user only when actor_id is supplied on create; Actors are never auto-created here. 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.
Session cost
GET /api/v1/sessions/{session_id} carries a usage object — cost_usd plus input_tokens, output_tokens, cached_tokens, cache_write_tokens and reasoning_tokens — summed across every metered generation dispatched through the session. It is the same shape an orchestration run reports.
- Single read only. Session and fork listings omit the field.
- A fork starts at zero. It does not inherit what the copied history cost, so summing
usageacross a session and its forks never double-counts. - Only its own generations. Work done around a session without a generation of its own (a memory extraction pass, for instance) is metered on the project and appears in no session's figure.
For a window, a split by day or model, or one end user across every session, use the aggregate: GET /api/v1/usage/aggregate?session_id=…. See End-user attribution.
Lifecycle
A session starts open and can be updated to closed. 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.
Sessions on a shared agent
A key or OAuth token scoped to a project that accepted a share of an agent opens a session on it in that project: the turns run on the publisher's configuration and are recorded, metered and governed in the grantee. Deleting the agent in the publisher's project leaves the session readable, and a new turn answers 400 AGENT_NOT_FOUND.
Deletion
DELETE .../sessions/:session_id removes the session row and its underlying Conversation row in the same transaction; the conversation cascades at the database level to every message in it.
Deletion does not remove:
- The session's actor. The Actor referenced by
actor_idis left untouched. - Documents backing message content. Each message's content is a Document row; those documents (and their underlying files) remain.
- Generations and traces. A session's generations and traces are not linked to the session or conversation record and remain queryable via
GET /api/v1/traces/{trace_id}.
Delete these explicitly beforehand for 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 (e.g. a stricter prompt or another agent version against the context that produced a bad answer at message 7).
- CLI
- SDK
- curl
soat fork-session \
--session-id "$SESSION_ID" \
--fork-at-position 7 \
--agent-id agent_V1StGXR8Z5jdHi6B \
--name "retry with stricter system prompt" \
--tags '{ "experiment": "prompt-v2" }'
const { data, error } = await soat.sessions.forkSession({
path: { session_id: SESSION_ID },
body: {
fork_at_position: 7,
agent_id: 'agent_V1StGXR8Z5jdHi6B',
name: 'retry with stricter system prompt',
tags: { experiment: 'prompt-v2' },
},
});
if (error) throw new Error(JSON.stringify(error));
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 (400 AGENT_NOT_FOUND) |
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. One stored copy of the content means a retention purge erases it from parent and fork together.
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 a fork cannot send an email or charge a card a second time; a forked turn sees the tool data as it was, which suits comparison, not resuming a session for real.
The fork is inert. auto_generate is false and no generation is triggered; drive the branch with POST .../messages and POST .../generate. It 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 those agents.
Lineage reads back on the session itself (forked_from_session_id, forked_from_position) and from the parent:
- CLI
- SDK
- curl
soat list-session-forks --session-id "$SESSION_ID"
const { data, error } = await soat.sessions.listSessionForks({
path: { session_id: SESSION_ID },
});
if (error) throw new Error(JSON.stringify(error));
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; their forked_from_session_id becomes null.
Forking requires both agents:GetSession and agents:CreateSession.
Auto-Generate
When auto_generate is true, POST .../messages saves the user message and triggers LLM generation in the same request; the response body is the generated turn instead of the saved user message. auto_generate defaults to false and can be set at creation or toggled via PATCH /sessions/{session_id}.
The explicit POST .../generate endpoint works regardless of this setting.
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 called once the 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 in context.
POST .../messages always returns immediately with the saved user message; generation fires asynchronously after the delay. message_delay_seconds has no effect when auto_generate is false or when a generation is already in progress.
Context window
A generation sends the conversation's most recent messages and drops the oldest: the session's max_context_messages when set, else the agent's max_context_messages, else the whole history. The agent's instructions are not counted. The value is a positive integer; 0 or a negative is 400 VALIDATION_FAILED. A fork keeps its parent's value.
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.
Idempotency
Channels like WhatsApp use at-least-once webhook delivery, so 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 and applies to both the message and the document_id variant. message, document_id and tool_context are the request a key names: reusing a key with any of them changed is 409 IDEMPOTENCY_KEY_REUSED and saves nothing. The key's format, the Idempotency-Key header and how long a key lasts are in Idempotency.
The saved message carries the key as idempotency_key in the session's conversation messages, so the transcript row for a provider message is found by its provider id.
Inactivity TTL
inactivity_ttl_seconds expires a session after a period of inactivity:
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, so the change 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.
The bag is write-only: it is accepted on create and update, and no read of a session returns it. It is what the session's tools authorize with — often a per-user token — so reading a session is not a way to obtain one. The reserved identity keys below are dropped from a stored bag, in any casing.
It does not outlive the session either: closing or expiring one — a caller-driven PATCH .../status: "closed", or the inactivity TTL expiring it — clears the bag, since a closed session can never dispatch another tool call.
When a generation is triggered through a session, the server injects these 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: a caller-provided actor_id, actor_external_id or session_id, in the session's stored tool_context or a per-request one, is ignored. Any other key still wins in the usual way (a per-request value overrides the session's stored value).
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 202 Accepted immediately. Pass ?wait=true to block until the generation settles and receive the generated turn. 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 in flight, the server cancels the previous generation and starts a fresh one, so the model always sees the complete message history.
Debugging (Session, Generation, Trace)
Each call to POST .../generate returns generation_id and trace_id. Store these alongside session_id:
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
Dispatched to project webhooks as sessions change state. sessions.created, sessions.updated and sessions.deleted carry the session as GET /api/v1/sessions/{session_id} answers it, usage included; a deletion carries it as it stood. A tag write is a sessions.updated.
| 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_started | LLM generation has started for a session |
Every session event names the session in resource_id. A session turn settles as a generation event (generations.completed, generations.failed, generations.requires_action) whose record's session_id names the session. Permissions are namespaced under agents: since each session belongs to an agent.
Who may fork a session
Forking reads a session's full history as well as creating a new
session, so it checks both agents:GetSession and agents:CreateSession —
each against the parent session's SRN,
srn:<project_id>:session:<session_id>. The fork's own id does not exist yet,
and the parent is the resource whose history the call reaches, so
agents:CreateSession alone is never a way to read a history
GET /api/v1/sessions/{session_id} would refuse.
{
"statement": [
{
"effect": "Allow",
"action": ["agents:GetSession", "agents:CreateSession"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:session:sess_V1StGXR8Z5jdHi6B"]
}
]
}
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>"