A transcript is evidence, not memory
Every conversation your agents have is already recorded somewhere, in a format nobody will ever read again. Sessions takes those transcripts — with their threads, their branches and their turn order intact — keeps them searchable at message level, and promotes what matters into memory the retrieval engine can actually use.
- 4 endpoints
- 5,000 messages per call
- Threads and branches preserved
- Webhook on every ingest
Capture, summarise, promote
Three things have to happen to a transcript before it earns its storage. Doing only the first is an archive; doing all three is memory.
-
Capture the transcript as it was
Messages land in session_messages with their role, their timestamp, an estimated token count and a Postgres-generated search vector over the content. The session header keeps the title, the platform, the model and the start and end times. Re-ingesting the same session id updates it rather than duplicating it, and individual messages are inserted with a do-nothing conflict rule — so a client that replays the last twenty turns does not create twenty copies.
-
Summarise the whole thing once
On ingest the transcript is flattened to role-prefixed lines and summarised. With a provider configured, that is a model call. With no key at all, it falls back to a deterministic extractive summary — shorter and blunter, but produced without a network hop, a bill or a dependency. The summary is stored on the session row, and a later ingest will not overwrite a good summary with a null one.
-
Promote it into durable memory
The summary is written as a semantic memory titled with the session, with the session id in source_ids and a meta layer of session_summary, at confidence 0.7. From that moment it is an ordinary memory: embedded, indexed, retrievable, prunable, pinnable. The trust table rates a session summary at 0.58 authority — below a semantic memory you wrote yourself, well below a current fact — so it informs an answer without ever outranking one.
One endpoint takes the whole conversation
POST /v1/sessions/ingest accepts a session header and
its messages in a single call. Nothing about it assumes the
conversation happened here — the point is to absorb transcripts from
wherever your agents actually run.
messagessession_idsource · platform · modelthread_id · branch_idturn_index · parent_message_idsummarizestore_summary_memory{
"session_id": "cs-2026-04-09-ardal",
"source": "claude-code",
"title": "Ledger migration kickoff",
"messages": [
{ "role": "user", "turn_index": 0,
"thread_id": "main",
"content": "are they still net-30?" },
{ "role": "assistant", "turn_index": 1,
"parent_message_id": "…",
"content": "net-45 from April." }
]
}
// → session record, then session.ingested
Conversations are not lists
Real agent sessions fork. Somebody retries a prompt, a tool call spawns a sub-conversation, a branch gets abandoned halfway. Flattening that into one ordered array destroys the only information that made the transcript worth keeping.
Four fields carry the structure
thread_id separates parallel conversations inside one
session. branch_id separates alternative continuations
inside a thread. turn_index orders them.
parent_message_id records what a message was replying
to. Both identifiers default to main, so a flat
transcript stays simple and a forked one stays truthful.
The index matches the question
Messages are indexed on tenant, user, session, thread and turn
together — which is the exact shape of “give me turns 8 to 12 of the
main thread”. Ordering falls back to created_at when a
turn index is missing, so a partially annotated import still reads in
the right order.
{ "query": "net-45", "expand_adjacent": 2 }
// → the hit, and its context
turn 5 user adjacent
turn 6 assistant adjacent
turn 7 user primary
turn 8 assistant adjacent
turn 9 user adjacent
// same session, same thread, deduplicated
A matching line is rarely the answer
Search ranks messages with ts_rank_cd over a
websearch_to_tsquery, and falls back to plain containment
so a partial token still finds something. An empty query returns the
most recent messages rather than an error.
The useful part is expand_adjacent. Set a window of up to
ten and every hit brings back the turns either side of it, from the
same session and the same thread, deduplicated across overlapping
hits and labelled primary or adjacent. You
get the exchange, not the fragment — which is the difference between
“net-45” and “net-45 from April, but only on the retainer”.
The same search runs as GET or POST, because
an MCP client and a browser want different things.
Reading a session back
GET /v1/sessions/{id}/timelineGET /v1/sessions/{id}/replayrole: content transcript ready to paste into a prompt or
a ticket. Replay is what you reach for when somebody asks what the
agent was actually told before it did the thing it did.
session.ingestedTwo things worth knowing before you wire it up
Session capture writes to Postgres when Postgres is the configured backend. Without it, sessions are appended to a local JSONL file — fine for a laptop or a first experiment, and not the thing to run a team on. Threads, branches, adjacency and search all work on both paths; only the storage differs.
Summarisation degrades rather than fails. No provider key means an extractive summary rather than a written one, and the resulting memory carries the same reduced confidence either way. Nothing about the capture path stops working because a model was unreachable.
Keep reading
Give your agents a memory you can audit
Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.