Sessions and session IDs
A session is a durable conversation between one end user and one agent. It holds every message, in order, and it is what gives the agent its memory of the conversation. You can address a session by the ID we issue (sess_…) or by your own ID (external_id), such as an order number, a ticket or a chat thread in your system.
The session ID contract
Section titled “The session ID contract”This contract is part of the API’s stability promise.
- We always mint the ID. Every session has
id = sess_<26 chars>. It is unguessable and time-sortable. - You may add your own
external_id:- 1–128 characters matching
^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$(an underscore is fine, as incus_123); - case-sensitive;
- unique within the project;
- set once and immutable;
- must not start with
sess_.
- 1–128 characters matching
- Both IDs work everywhere a session appears in a URL. Responses always return both.
- Get-or-create.
POST /v1/sessionswith anexternal_id:- returns
201(created: true) or200(created: false); if_exists: "error"makes a match return409 session_exists;- the same
external_idwith a different agent returns409 session_agent_mismatch; - closed sessions reopen unless
if_closed: "error"; - concurrent first calls are safe (a unique index plus an upsert).
- returns
- IDs are not credentials.
- Every call is authenticated.
- Client tokens can only reach sessions of their own end user.
- Browsers never address sessions by
external_idwith a publishable key alone.
- No personal data in IDs. We return
warnings: ["external_id_looks_like_phone"](or…_email) when an ID looks like contact details. Hash such values on your server first (see below), and keep contact details in the end user’straitsinstead. - Memory and limits follow the end user, not the session ID. Rotating session IDs does not reset the action ledger or per-user limits for a verified end user.
- History spans the whole session (the last
history_limitmessages). Idle gaps only refresh the frozen prompt and agent version (segments) and are independent of billing windows.
What else is guaranteed
Section titled “What else is guaranteed”- Only three things create sessions:
POST /v1/sessions,POST /v1/widget/sessionsand the OpenAI-compatible endpoint when you name a session. Any other URL with an unknown{session}returns404 session_not_found. - Defaults:
if_existsdefaults to"resume"andif_closedto"reopen". inputonPOST /v1/sessionsis always appended and answered, whether the session was just created or resumed. One call per turn is a complete integration.- A session’s end user is fixed at creation. Resuming with a different
end_user.external_idreturns409 session_end_user_mismatch. Other creation-only fields are ignored on resume and the response says so withwarnings: ["create_params_ignored"]. - How a path is read:
{session}is treated as our ID only when it issess_followed by 26 lowercase base32 characters. Anything else is looked up as anexternal_id. - The playground is separate. Test sessions from the dashboard live in their own
external_idnamespace and never resolve to, or write into, a real session.
Get or create a session
Section titled “Get or create a session”curl https://api.k-agent.kerneltics.com/v1/sessions \ -H "Authorization: Bearer $KAGENT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "agent": "store-assistant", "external_id": "order-8812", "end_user": { "external_id": "cus_1042", "name": "Fahad" }, "metadata": { "source": "order_page" }, "input": "Do you deliver to Abha?" }'const res = await fetch('https://api.k-agent.kerneltics.com/v1/sessions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.KAGENT_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ agent: 'store-assistant', external_id: 'order-8812', end_user: { external_id: 'cus_1042', name: 'Fahad' }, metadata: { source: 'order_page' }, input: 'Do you deliver to Abha?', }),});const body = await res.json();// 201 on the first call, 200 when the session already existed.console.log(res.status, body.created, body.session.id, body.run?.output_text);import os, uuid, requests
r = requests.post( "https://api.k-agent.kerneltics.com/v1/sessions", headers={"Authorization": f"Bearer {os.environ['KAGENT_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, json={ "agent": "store-assistant", "external_id": "order-8812", "end_user": {"external_id": "cus_1042", "name": "Fahad"}, "metadata": {"source": "order_page"}, "input": "Do you deliver to Abha?", }, timeout=120,)body = r.json()# 201 on the first call, 200 when the session already existed.print(r.status_code, body["created"], body["session"]["id"])The first call returns 201:
{ "session": { "id": "sess_01k6rz4p7h2c9m5x8w3t6v1qbg", "object": "session", "external_id": "order-8812", "end_user_id": "eu_01k6rz3t5v7w9x1y2z4a6b8c0d", "channel": "api", "status": "active", "mode": "agent", "concurrency": "queue", "version_policy": "latest", "metadata": { "source": "order_page" }, "created_at": 1791271920 }, "created": true, "message": { "id": "msg_01k6rz5b2c4d6e8f0g1h3j5k7m", "object": "message", "role": "user" }, "run": { "id": "run_01k6rz5a9d3f6g2h8j4k7m1n5p", "object": "run", "status": "completed", "outcome": "answered", "output_text": "Yes, we deliver to Abha in 3 to 5 working days, and delivery is free on orders over SAR 200." }, "warnings": []}Every later call with the same external_id returns 200 with "created": false, appends the new input and answers it.
Parameters
Section titled “Parameters”| Field | Type | Notes |
|---|---|---|
agent |
string, required | Agent ID or slug. |
external_id |
string | Your ID for the session; see the contract above. Leave it out to use only our sess_ ID. |
end_user |
object | {external_id, name?, traits?}. Secret keys may vouch for the user, which makes them verified. Fixed for the life of the session. |
variables |
object | Values for the agent’s declared variables. Secret variables are not accepted here. |
metadata |
object | Up to 16 keys; keys up to 64 characters, values up to 512. Never sent to the model. |
concurrency |
queue · reject · interrupt |
What happens when a message arrives while the agent is answering. |
version_policy, version |
latest · pinned |
Pin the session to one published version, or follow the latest. |
if_exists |
resume · error |
Default resume. |
if_closed |
reopen · error |
Default reopen. |
input |
string or text parts | Appended and answered in the same call. |
overrides |
object | Session-level overrides the agent allows, applied from the next segment. Secret keys only. |
client_message_id |
string | Deduplicates input, as on messages. |
stream, background, wait_seconds |
Stream the reply, return at once, or wait up to wait_seconds (default 60, max 110). The status stays 201 or 200 either way; a run that is still working is returned as it is. |
When the answer is 409 or 422
Section titled “When the answer is 409 or 422”| Situation | Response |
|---|---|
if_exists: "error" and the session exists |
409 session_exists |
Same external_id, different agent |
409 session_agent_mismatch |
Same external_id, different end_user.external_id |
409 session_end_user_mismatch |
if_closed: "error" and the session is closed |
409 session_closed |
external_id breaks the format, e.g. sess_x or order 8812 |
422 external_id_invalid |
{ "error": { "type": "conflict_error", "code": "session_agent_mismatch", "message": "This external_id already belongs to a session with a different agent.", "param": "external_id", "request_id": "req_01k6rz9f1j3m5p7r9t1w3y5a7c", "doc_url": "https://k-agent.kerneltics.com/docs/en/reference/errors/#session_agent_mismatch" }}If you run several agents over the same records, give each its own ID space, for example support:order-8812 and sales:order-8812.
Keep personal data out of IDs
Section titled “Keep personal data out of IDs”IDs show up in logs, URLs and exports. When an external_id looks like a phone number (8 or more digits) or an email address, the response carries a warning:
{ "warnings": ["external_id_looks_like_phone"] }If your natural key is personal data, derive the ID on your server with a keyed hash. The result is stable for the same input, reveals nothing, and fits the external_id format: base32(HMAC-SHA256(secret_you_keep, value)), lowercase, first 32 characters. K-Agent never sees or stores your secret.
import { createHmac } from 'node:crypto';
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
export function hashExternalId(value, secret) { const digest = createHmac('sha256', secret).update(value, 'utf8').digest(); let out = ''; let bits = 0; let acc = 0; for (const byte of digest) { acc = ((acc << 8) | byte) & 0xffff; bits += 8; while (bits >= 5) { out += ALPHABET[(acc >>> (bits - 5)) & 31]; bits -= 5; } } return out.slice(0, 32);}
hashExternalId('+966501234567', process.env.ID_HASH_SECRET);import base64, hashlib, hmac, os
def hash_external_id(value: str, secret: bytes) -> str: digest = hmac.new(secret, value.encode("utf-8"), hashlib.sha256).digest() return base64.b32encode(digest).decode("ascii").lower()[:32]
hash_external_id("+966501234567", os.environ["ID_HASH_SECRET"].encode())import ( "crypto/hmac" "crypto/sha256" "encoding/base32" "strings")
func hashExternalID(value string, secret []byte) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte(value)) enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil)) return strings.ToLower(enc)[:32]}function hash_external_id(string $value, string $secret): string{ $digest = hash_hmac('sha256', $value, $secret, true); $alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; $bits = ''; foreach (str_split($digest) as $byte) { $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT); } $out = ''; foreach (str_split(substr($bits, 0, 160), 5) as $chunk) { $out .= $alphabet[bindec($chunk)]; } return $out;}All four produce the same value: hashExternalId('+966501234567', 'my-hmac-secret') is cs6vd2wrba2hf5w2ozuvftyoq5dewzhb. Keep the secret stable — changing it changes every ID.
Concurrency
Section titled “Concurrency”Two messages can arrive while the agent is still answering the first. The session’s concurrency decides what happens:
| Policy | Behavior | Default for |
|---|---|---|
queue |
Messages wait their turn and are answered in order. At most 10 can wait; the 11th gets 409 session_queue_full. |
API sessions |
reject |
A message that arrives during a reply gets 409 session_busy with Retry-After: 2. Nothing is stored. |
— |
interrupt |
The reply in progress is abandoned (superseded) and one new reply answers every unanswered message together. |
Widget sessions |
A superseded run never executes a tool or writes output after it is replaced. Use interrupt for chat UIs where people send several short messages in a row.
History and segments
Section titled “History and segments”- History spans the whole session. Each reply sees the last
history_limitmessages (default 12), cut at a user message so an exchange is never split. Coming back the next day does not reset the conversation. - History is text. Earlier turns are sent as plain text; tool calls and model reasoning stay inside the run that made them.
- Your team’s replies count. Public
human_agentmessages appear to the agent as assistant turns after a handoff ends; internal notes never do. - Segments are the parts of a session between idle gaps (
idle_timeout_minutes, default 30). At the start of a segment the agent version, the prompt and the tool list are resolved and frozen until the next segment. A new segment also starts after you publish a new version, when the session’s variables change, or after 24 hours. Segments never cut history and have nothing to do with billing.
Lifecycle
Section titled “Lifecycle”| Action | How |
|---|---|
| Read a session | GET /v1/sessions/{session} (ID or external_id) |
Change metadata, variables, overrides or concurrency, or clear flagged |
PATCH /v1/sessions/{session} |
| Close | POST /v1/sessions/{session}/close. A new user message reopens it. |
| Delete for good | DELETE /v1/sessions/{session} — removes messages, runs, events, handoffs and tickets. |
| List and filter | GET /v1/sessions?agent=&end_user=&external_id=&status=&mode=&flagged=&channel=&created_after=&created_before= |
| Read the transcript | GET /v1/sessions/{session}/messages |
| Follow live | GET /v1/sessions/{session}/events — see Runs and streaming |
Sending messages
Section titled “Sending messages”POST /v1/sessions/{session}/messages takes input plus optional client_message_id, stream, background, wait_seconds, variables and metadata, and returns {session, message, run}:
client_message_idmakes a message safe to resend: the same ID returns the original{session, message, run}withIdempotent-Replayed: trueinstead of a second answer. The same ID with different text returns409 client_message_id_conflict.- Human mode: while your team has the conversation, the message is stored,
runisnull, andsession.modeis"human". background: truereturns202at once with the run in progress; follow it withGET /v1/runs/{run}or the session’s event stream.
Your team replies through the same endpoint with role: "human_agent" — see the Handoff Desk guide.