Sessions
Durable, resumable dialogues with an agent — messages, generations, forks and expiry.
Overview
A session binds one agent to one thread of conversation. Creating it also creates the conversation underneath, so a single call is enough to start talking; the transcript is then read through that conversation, because naturali stores no message bodies of its own.
On top of a bare conversation, a session adds an agent binding, a lifecycle
(open / closed / expired), optional auto-generation, inactivity expiry,
message debouncing, and forking.
This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Sessions.
Data Model
Session
| Field | Type | Description |
|---|---|---|
id | string | Public session ID. |
agent_id | string | The agent that answers in this session. Required on create. |
conversation_id | string | The conversation holding the transcript. |
status | string | open, closed or expired. |
name | string, nullable | Optional label. |
actor_id | string, nullable | The actor speaking as the end user, or null — see Cap spend per end user. |
auto_generate | boolean | When true, posting a user message triggers generation automatically. |
generating_at | string (date-time), nullable | When the in-flight generation started, else null. |
tool_context | object, nullable | Key/value pairs forwarded as X-Naturali-Context-<key> headers on every http and mcp tool call in this session. |
inactivity_ttl_seconds | integer, nullable | Seconds of inactivity after which the session expires. 0 never expires. |
message_delay_seconds | integer, nullable | Debounce: wait this long after the last user message before calling the model; each new message resets the timer. |
last_activity_at | string (date-time) | |
forked_from_session_id | string, nullable | The session this one branched from, if any. |
forked_from_position | integer, nullable | The parent position it branched after. |
tags | object, nullable | Tag map. |
usage | object | Token counts and cost_usd summed across the session's generations. Single-session read only — the listing and the fork listing omit it. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
Posting a message, with or without auto-generation
POST /v1/projects/{project_id}/sessions/{session_id}/messages
saves a user message. If auto_generate is on and nothing is generating, it also
starts the run and answers like a generate call; otherwise it returns the saved
message and you call
POST …/generate yourself.
Replay a bad answer
posts with it on;
Cap spend per end user adds
the message and generates in two calls.
idempotency_key on the message body deduplicates within the session: the same
key returns the original message with 200 and starts no second generation —
which is what makes a retrying webhook safe.
Generation is background by default
POST …/generate answers
202 Accepted with a generation_id unless you pass wait=true. Poll the
generation for the result. A client-type
tool pauses the run with requires_action and a required_action
payload; resume it with
POST …/tool-outputs, which
takes the generation_id and one output per pending call.
On a managed provider the model the session's agent would
run must carry a price, or the call answers 503 model_not_priced and generates
nothing; a project that owes for usage already served answers
402 insufficient_credit until it is topped up. See
A managed model must carry a price
and A negative balance stops managed generation.
A Free account that has used its plan's monthly runs answers
403 plan_limit_reached with resource: "runs" here as well, on its own
credential too. See
A plan's run allowance can stop generation.
Handing the outputs back with
POST /v1/projects/{project_id}/sessions/{session_id}/tool-outputs
is what makes the next call, so it answers 402 insufficient_credit on the same
balance. It needs no price check: the generation it continues cleared one when it
started, on the same model. Adding a message is refused the same way only on a
session with auto_generate on, where the message starts the generation;
otherwise it sits in the session until something generates on it.
Debouncing a chatty user
message_delay_seconds waits that many seconds after the last user message
before calling the model, and each new message resets the timer. Three messages
typed in quick succession become one generation with all three in context,
instead of three runs racing each other.
What a session cost
GET /v1/projects/{project_id}/sessions/{session_id}
carries a usage roll-up: the token counts and cost_usd of every metered
generation dispatched through the session. It answers "what did this
conversation cost" without joining the generations log
against a rate card.
A fork is a session of its own and starts at zero rather than inheriting what
the history it copied cost, so summing usage across a session and its forks
never double-counts. Work the platform does around a session without a
generation of its own is metered on the project, not here — read
GET /v1/projects/{project_id}/usage
for the project-wide view.
usage is one number. For the same spend split by day, model or provider, pass
the session to the project meter as session_id: see
narrowing to one session or one end user;
Cap spend per end user
splits it by end user.
The listing omits usage — reading it per session is a read per session, and a
customer's dashboard is better served by one narrowed rollup.
Expiry
inactivity_ttl_seconds expires a session after a quiet period — status becomes
expired and it stops accepting messages. 0 means it never expires.
Forking
POST …/fork branches a new session from a
point in this one's history: same context, different continuation. The fork gets
its own conversation whose messages reference the same documents as the
parent rather than copying them, so there is one stored copy of the shared
history. Omit fork_at_position to branch at the tip, and pass agent_id to
have a different agent continue.
GET …/forks lists one level of
lineage: a fork of a fork is listed under its own parent.
Replay a bad answer forks at
a customer's question so a fixed agent answers it again.
Deleting
DELETE removes the session, its
conversation and their messages. The actor is not deleted, and
neither are the generations the session produced — those are
records of what happened, not parts of the session.
Examples
Open a session and send the first message
- CLI
- SDK
- curl
SESSION_ID=$(naturali create-session \
--project-id proj_V1StGXR8Z5jdHi6B \
--agent-id agent_V1StGXR8Z5jdHi6B \
--auto-generate true | jq -r .id)
naturali add-session-message \
--project-id proj_V1StGXR8Z5jdHi6B \
--session-id "$SESSION_ID" \
--message "Where is my order?"
const { data: session } = await naturali.sessions.createSession({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { agent_id: 'agent_V1StGXR8Z5jdHi6B', auto_generate: true },
});
const { data: reply } = await naturali.sessions.addSessionMessage({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B', session_id: session!.id },
body: { message: 'Where is my order?' },
});
SESSION_ID=$(curl -s -X POST \
https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/sessions \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "agent_V1StGXR8Z5jdHi6B", "auto_generate": true }' | jq -r .id)
curl -X POST \
"https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/sessions/$SESSION_ID/messages" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "message": "Where is my order?" }'
Read the transcript
The transcript lives on the session's conversation:
- CLI
- SDK
- curl
CONVERSATION_ID=$(naturali get-session \
--project-id proj_V1StGXR8Z5jdHi6B \
--session-id ses_V1StGXR8Z5jdHi6B | jq -r .conversation_id)
naturali list-conversation-messages \
--project-id proj_V1StGXR8Z5jdHi6B \
--conversation-id "$CONVERSATION_ID"
const { data: session } = await naturali.sessions.getSession({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
session_id: 'ses_V1StGXR8Z5jdHi6B',
},
});
const { data: messages } =
await naturali.conversations.listConversationMessages({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
conversation_id: session!.conversation_id,
},
});
CONVERSATION_ID=$(curl -s \
https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/sessions/ses_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" | jq -r .conversation_id)
curl "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/conversations/$CONVERSATION_ID/messages" \
-H "Authorization: Bearer $NATURALI_TOKEN"
Fork a session to try a different continuation
- CLI
- SDK
- curl
naturali fork-session \
--project-id proj_V1StGXR8Z5jdHi6B \
--session-id ses_V1StGXR8Z5jdHi6B \
--fork-at-position 4 \
--name "alternative reply"
const { data: fork } = await naturali.sessions.forkSession({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
session_id: 'ses_V1StGXR8Z5jdHi6B',
},
body: { fork_at_position: 4, name: 'alternative reply' },
});
curl -X POST \
https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/sessions/ses_V1StGXR8Z5jdHi6B/fork \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "fork_at_position": 4, "name": "alternative reply" }'
Close a session
- CLI
- SDK
- curl
naturali update-session \
--project-id proj_V1StGXR8Z5jdHi6B \
--session-id ses_V1StGXR8Z5jdHi6B \
--status closed
await naturali.sessions.updateSession({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
session_id: 'ses_V1StGXR8Z5jdHi6B',
},
body: { status: 'closed' },
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/sessions/ses_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "status": "closed" }'