Skip to main content

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.

Data Model​

Session​

FieldTypeDescription
idstringPublic identifier prefixed with sess_
agent_idstringPublic ID of the agent this session belongs to; kept after a shared agent is deleted by its owner
conversation_idstringPublic ID of the underlying conversation
statusstringopen (default), closed, or expired
namestringOptional display name
actor_idstring | nullOptional public ID of the Actor linked on create (actor_ prefix); null when none was supplied
tagsobjectFree-form key-value metadata
auto_generatebooleanWhen true, saving a message automatically triggers LLM generation (default: false)
message_delay_secondsinteger | nullDebounce delay in seconds before the LLM is called after a user message. null means no delay (default).
inactivity_ttl_secondsintegerSeconds of inactivity before the session expires. 0 means never expires (default: 0)
max_context_messagesinteger | nullMost recent messages sent to the model per generation, over the agent's own; null defers to the agent (default). See Context window
last_activity_atstring | nullISO 8601 timestamp of the last user message; null until the first message is added
forked_from_session_idstring | nullPublic ID of the session this one was forked from; null when it is not a fork, or once the parent is deleted
forked_from_positioninteger | nullParent conversation position this session branched after; null when it is not a fork or was forked at the tip
usageobjectWhat the session's generations cost — cost_usd and token counts. Single-session read only; see Session cost
created_atstringISO 8601 creation timestamp
updated_atstringISO 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 body
  • document_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.

FieldTypeDescription
statusstringcompleted or requires_action
contentstringThe generated text. completed only
messageobjectThe stored assistant Conversation Message, as the conversation lists it; its document_id addresses the reply. completed only
modelstringModel used. completed only
generation_idstringThe generation that produced the turn
trace_idstringThe generation's trace
required_actionobjectClient 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​

ConceptRelationship
ChatsRaw LLM completions — no agents, no tools, caller manages history
Sessions1 user ↔ 1 agent — full tool support, automatic history, owned by an agent
ConversationsMulti-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 usage across 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_id is 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).

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" }'
FieldDefaultMeaning
fork_at_positionbranch at the tipParent position to branch after; positions 0..N are carried over
agent_idthe parent's agentThe agent the fork runs against. Must be in the same project (400 AGENT_NOT_FOUND)
name, tags—Set on the new session
tool_contextinheritedOverrides 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:

soat list-session-forks --session-id "$SESSION_ID"

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_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 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 keyForwarded headerValue
actor_idX-Soat-Context-actor_idPublic ID of the session's actor; omitted if not set
actor_external_idX-Soat-Context-actor_external_idExternal ID of the session's actor; omitted if not set
session_idX-Soat-Context-session_idPublic 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:

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 typeTrigger
sessions.createdA new session is created
sessions.updatedA session's name, status, or tags are changed
sessions.deletedA session is deleted
sessions.generation_startedLLM 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​

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

List sessions​

Filter by agent, actor, or status.

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