---
title: Sessions
summary: Keep a conversation across runs, queue messages, and reconnect clients.
canonical: https://docs.caveman.so/docs/agent-sdk/sessions
license: Apache-2.0
capability: agent-sdk
updated: 2026-09-16T21:32:40-07:00
basis: inferred
---

# Sessions

> Keep a conversation across runs, queue messages, and reconnect clients.
A session is one conversation carried across consecutive runs. `createConversation()` holds it in your own process; the agent server holds it behind HTTP, where every client attached to the session reads the same event stream and a message that arrives during a run joins that run instead of starting a second one.

Rendering the stream in a browser? The bearer token spends money, so it stays on your server: [React clients](/docs/agent-sdk/react).

```ts
import { agent, auto, createConversation, run } from "@caveman-ai/agent";

const support = agent({ id: "support", instructions: "Answer from policy.", model: auto() });

const conversation = createConversation();
const first = await run(support, "Where is order A-123?", { conversation });
const second = await run(support, "Which carrier?", { conversation });

console.log("sessionId:", conversation.sessionId);
console.log("first:", JSON.stringify(first.text));
console.log("second:", JSON.stringify(second.text));
console.log("messages:", conversation.snapshot().messages.length);
console.log("roles:", conversation.snapshot().messages.map((m) => m.role));
```

Unreleased: this page documents `@caveman-ai/agent` 0.2.0 from source at commit 311be40 in caveman-ai/agent-sdk. Build it from the checkout as described on [Install](/docs/agent-sdk/install).

Captured from that script with a scripted model from `@caveman-ai/agent/testing`, so the answers are fixed:

```console
sessionId: conversation-27a5c24f-…
first: "Order A-123 shipped on Tuesday."
second: "It was DHL."
messages: 4
roles: [ 'user', 'assistant', 'user', 'assistant' ]
```

The second run saw the first one's turn. `conversation.sessionId` is minted at construction and `snapshot()` returns a structured clone, so nothing outside the runtime can edit the transcript in place.

## Sessions over HTTP

`createAgentServer` owns sessions, their run ids, and their event stream. One agent definition, one bearer token of at least 16 characters.

```ts
import { agent, auto } from "@caveman-ai/agent";
import { createAgentServer } from "@caveman-ai/agent/serve";

const support = agent({ id: "support", instructions: "Answer from policy.", model: auto() });

const server = createAgentServer({
  definition: support,
  token: process.env.CAVE_SERVE_TOKEN!,
});
await server.listen(8080);
```

| Route | Body | Answer |
|---|---|---|
| `POST /sessions` | `{sessionId}` | `201 {"sessionId":"case-42"}` |
| `POST /sessions/{id}/messages` | `{text, author?, mode?, context?}` | `202 {"runId":"case-42.1","queued":false}` |
| `GET /sessions/{id}` | | `200` with `runs`, `active`, `queued`, `messages` |
| `GET /sessions/{id}/events` | | Server-Sent Events, across every run in the session |
| `DELETE /sessions/{id}` | | `202 {"sessionId":"case-42","status":"deleted"}` |
| `GET /sessions/{id}/ws` | | WebSocket upgrade carrying the same frames, bidirectional |

The caller assigns the session id and the server derives each run id from it as `<sessionId>.<n>`. `mode` is `followUp` (default) or `steer`. The id is validated as `<id>.1`, so it matches `[A-Za-z0-9][A-Za-z0-9._-]{0,127}` with two characters already spent and its ceiling is 126 characters. Anything else answers `400 {"error":"cave_durable_run_id_invalid"}`.

Against a local server running a scripted model:

```console
$ curl -s -X POST localhost:8080/sessions \
    -H "authorization: Bearer $CAVE_SERVE_TOKEN" \
    -H "content-type: application/json" -d '{"sessionId":"case-42"}'
{"sessionId":"case-42"}

$ curl -s -X POST localhost:8080/sessions/case-42/messages \
    -H "authorization: Bearer $CAVE_SERVE_TOKEN" \
    -H "content-type: application/json" -d '{"text":"Where is order A-123?","author":"Ada"}'
{"runId":"case-42.1","queued":false}
```

A request with no bearer, or the wrong one, answers `401 {"error":"cave_serve_unauthorized"}`. The comparison is length-independent.

## One stream across many runs

`GET /sessions/{id}/events` outlives the run that is active when you attach. Attach to a fresh session, then send to it:

```bash
curl -N localhost:8080/sessions/case-43/events -H "authorization: Bearer $CAVE_SERVE_TOKEN"
```

Two messages two seconds apart, both answered on that one open stream, against a server running a scripted model:

```text
id: 0
data: {"v":1,"seq":0,"ts":"2026-09-17T03:42:46.774Z","sessionId":"case-43","kind":"turn.start"}

id: 1
data: {"v":1,"seq":1,"ts":"…","sessionId":"case-43","kind":"usage","usage":{"in":100,"out":10,"cacheRead":0,"cacheWrite":0,"costUsd":null,"model":"faux-1"}}

id: 2
data: {"v":1,"seq":2,"ts":"…","sessionId":"case-43","kind":"turn.end","stopReason":"end_turn"}

id: 3
data: {"v":1,"seq":3,"ts":"…","sessionId":"case-43","kind":"turn.start"}

id: 4
data: {"v":1,"seq":4,"ts":"…","sessionId":"case-43","kind":"usage","usage":{"in":100,"out":10,"cacheRead":0,"cacheWrite":0,"costUsd":null,"model":"faux-1"}}

id: 5
data: {"v":1,"seq":5,"ts":"…","sessionId":"case-43","kind":"turn.end","stopReason":"end_turn"}
```

Timestamps are trimmed to `…` after the first. A scripted model hands the runtime one finished message, so the frames here are the turn envelope alone; a real provider streams the answer and adds `delta.text` frames between `turn.start` and `usage`. The sequence keeps counting across the run boundary, and the SSE event id is that sequence, so an `EventSource` reconnect resumes from `Last-Event-ID` and every frame arrives once. `costUsd` is `null` when any message in the turn was unpriced: unknown, never zero.

The retained window is bounded and in memory. Resuming from a sequence the server no longer holds sends a gap notice first, `{"error":"cave_serve_events_gap","requestedSeq":…,"earliestSeq":…}`, then the whole retained window. The transcript is complete from that point forward, and the run journal stays the record of what happened.

## Messages that arrive during a run

A second message on a busy session queues onto the active run. It gets the same `runId` and `queued: true`.

```console
$ curl -s -X POST localhost:8080/sessions/case-88/messages … -d '{"text":"Where is order A-123?"}'
{"runId":"case-88.1","queued":false}

$ curl -s -X POST localhost:8080/sessions/case-88/messages … -d '{"text":"and A-124?"}'
{"runId":"case-88.1","queued":true}

$ curl -s localhost:8080/sessions/case-88 -H "authorization: Bearer $CAVE_SERVE_TOKEN"
{
  "sessionId": "case-88",
  "runs": [
    { "status": "pending", "runId": "case-88.1", "agentId": "support",
      "input": "Where is order A-123?", "startedAt": "…", "attempts": 1 }
  ],
  "active": "case-88.1",
  "queued": 1,
  "messages": [
    { "runId": "case-88.1", "text": "Where is order A-123?", "mode": "followUp", "queued": false, "at": "…" },
    { "runId": "case-88.1", "text": "and A-124?",            "mode": "followUp", "queued": true,  "at": "…" }
  ]
}
```

`mode: "followUp"` lets the current turn finish and hands the message to the next one. `mode: "steer"` puts it ahead of any queued follow-ups. Both drain one at a time at turn boundaries, so `queued` is exact rather than approximate. `GET /sessions/{id}` shows the text the caller sent; the composed input and the caller's principal stay off the wire.

`DELETE /sessions/{id}` cancels the active run and drops the queue:

```console
$ curl -s -X DELETE localhost:8080/sessions/case-99 -H "authorization: Bearer $CAVE_SERVE_TOKEN"
{"sessionId":"case-99","status":"deleted"}
```

## Driving a session from your own code

Outside the server, `AgentRunController` is the same handle the server uses. Pass one to `run()` and queue onto the live loop.

```ts
import { AgentRunController, agent, auto, createConversation, run } from "@caveman-ai/agent";

const support = agent({ id: "support", instructions: "Answer from policy.", model: auto() });
const controller = new AgentRunController();
const conversation = createConversation();

const pending = run(support, "Audit the last 200 orders.", { conversation, controller });
controller.followUp("Start with the refunds.");
controller.subscribe((state) => console.log(state.queued));
await pending;
```

`steer(text)` and `followUp(text)` queue, `clear(index?)` removes one or all, and `interrupt()` aborts the active work while keeping queued messages for the next run.

## Who can address a session

With `token`, one bearer owns every session. With `authenticate(request)`, the SDK asks your code who the caller is and namespaces sessions per principal: the storage key is the session id prefixed with a 16-character hash of the principal id, so one principal cannot read, steer, or delete another's, and a session outside your namespace reads as `404` rather than `403`. A hook that throws answers `401`.

```ts
createAgentServer({
  definition: support,
  authenticate: async (request) => verifyJwt(request.headers.get("authorization")),
});
```

## Limits

Sessions, their replay buffers, and their deletion tombstones live in the instance that created them, which is why one instance at a time holds the journal store's lease. That rule and the concurrency, queue and body-size defaults are on [Deploy](/docs/agent-sdk/deploy#limits).

A session recovered after a restart is rebuilt from its last journaled turn checkpoint, and the digest has to match. A checkpoint that fails to reproduce byte for byte leaves the session answering `409 {"error":"cave_session_conversation_unrecoverable"}`, so a conversation the runtime can prove is the only one it will continue.

Site-wide measurement words are on [Numbers and limits](/docs/counting). Serving, journaling, and boot recovery are on [Deploy](/docs/agent-sdk/deploy), and the run journal itself is on [Durable runs](/docs/agent-sdk/durable).
