# Sessions and session IDs

> The published contract for session IDs — our sess_ IDs and your own external_id — with get-or-create, conflicts, warnings, history and concurrency.

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

This contract is part of the API's stability promise.

1. **We always mint the ID.** Every session has `id = sess_<26 chars>`. It is unguessable and time-sortable.
2. **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 in `cus_123`);
   - case-sensitive;
   - unique within the **project**;
   - set once and immutable;
   - must not start with `sess_`.
3. **Both IDs work everywhere** a session appears in a URL. Responses always return both.
4. **Get-or-create.** `POST /v1/sessions` with an `external_id`:
   - returns `201` (`created: true`) or `200` (`created: false`);
   - `if_exists: "error"` makes a match return `409 session_exists`;
   - the same `external_id` with a different agent returns `409 session_agent_mismatch`;
   - closed sessions reopen unless `if_closed: "error"`;
   - concurrent first calls are safe (a unique index plus an upsert).
5. **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_id` with a publishable key alone.
6. **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](#keep-personal-data-out-of-ids)), and keep contact details in the end user's `traits` instead.
7. **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.
8. **History spans the whole session** (the last `history_limit` messages). Idle gaps only refresh the frozen prompt and agent version ([segments](#history-and-segments)) and are independent of billing windows.

### What else is guaranteed

- **Only three things create sessions:** `POST /v1/sessions`, `POST /v1/widget/sessions` and the [OpenAI-compatible endpoint](/docs/en/guides/openai-sdk/) when you name a session. Any other URL with an unknown `{session}` returns `404 session_not_found`.
- **Defaults:** `if_exists` defaults to `"resume"` and `if_closed` to `"reopen"`.
- **`input` on `POST /v1/sessions` is 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_id` returns `409 session_end_user_mismatch`. Other creation-only fields are ignored on resume and the response says so with `warnings: ["create_params_ignored"]`.
- **How a path is read:** `{session}` is treated as our ID only when it is `sess_` followed by 26 lowercase base32 characters. Anything else is looked up as an `external_id`.
- **The playground is separate.** Test sessions from the dashboard live in their own `external_id` namespace and never resolve to, or write into, a real session.

## Get or create a session

**curl**

```bash
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?"
  }'
```

**JavaScript**

```js
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);
```

**Python**

```python
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`:

```json
{
  "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

| 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](/docs/en/concepts/settings/#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](/docs/en/concepts/settings/#overrides) the agent allows, applied from the next segment. Secret keys only. |
| `client_message_id` | string | Deduplicates `input`, as on [messages](#sending-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

| 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` |

```json
{
  "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

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:

```json
{ "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.

**JavaScript**

```js
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);
```

**Python**

```python
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())
```

**Go**

```go
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]
}
```

**PHP**

```php
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

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

- **History spans the whole session.** Each reply sees the last `history_limit` messages (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_agent` messages 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

| 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](/docs/en/concepts/runs-and-streaming/) |

### 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_id`** makes a message safe to resend: the same ID returns the original `{session, message, run}` with `Idempotent-Replayed: true` instead of a second answer. The same ID with different text returns `409 client_message_id_conflict`.
- **Human mode:** while your team has the conversation, the message is stored, `run` is `null`, and `session.mode` is `"human"`.
- **`background: true`** returns `202` at once with the run in progress; follow it with `GET /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](/docs/en/guides/handoff-desk/).
