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.
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.
Captured from that script with a scripted model from @caveman-ai/agent/testing, so the answers are fixed:
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.
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:
$ 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:
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:
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.
$ 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:
$ 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.
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.
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.
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. Serving, journaling, and boot recovery are on Deploy, and the run journal itself is on Durable runs.