Storage
Where the chat of record lives — the durable conversation the UI renders and the agent reads back.
Postgres, behind agent/chat
The chat is transactional, relational data: Chat → Message, tied to Agent / Team. It needs strong consistency and clean pagination for the UI → Postgres is the right home.
model Chat {
id String @id // chat-{uuid}
agentId String
channel String // widget | telegram | api …
messages Message[]
createdAt DateTime @default(now())
@@index([agentId, createdAt])
}
model Message {
id String @id // msg-{uuid}
chatId String
role String // user | assistant | tool | system
content Json // text + tool refs + attachments
tokens Int?
createdAt DateTime @default(now())
chat Chat @relation(fields: [chatId], references: [id])
@@index([chatId, createdAt])
}The agent/chat slice owns this behind a gateway, so the physical store is swappable without touching callers.
Reading history: pages, not whole threads
All three history reads are keyset-paginated, and there is no way to ask for everything:
GET /agents/:id/chat/messages?limit=&cursor=
GET /agents/:id/chat/threads?limit=&cursor=
GET /agents/:id/chat/threads/:threadId/messages?limit=&cursor=limitdefaults to 50 and is capped at 200.- The answer is
{ items, nextCursor, hasMore }withitemsordered oldest → newest.hasMoreis its own field on purpose: inferring it fromitems.length === limitis wrong exactly once per thread — on a last page that happens to be full. cursoris opaque and walks backwards, into older rows. A cursor this api did not issue is rejected, not quietly ignored.
This is not a nicety. An agent with autostart writes to its thread around the clock, so a thread grows without anyone using the product.
Starting over — a line in the thread, not a second thread
A conversation had nowhere to end: something was settled, a week later somebody arrived with something else, and the model dragged the whole first subject into the second. "Start over" draws a boundary row in the same thread. From it the model is handed nothing that came before, while the person keeps every earlier message and scrolls to it.
There is still exactly one thread per (agent, channel, person) — the key says so — and no new kind of row: the boundary is the same marker compaction writes when it replaces older turns with a recap, placed deliberately instead of waiting for the thread to grow. Pressing the button twice does not stack two dividers.
What a restart does not touch is the agent's memory. Notes it wrote about the person are not in the thread, so after starting over it still knows them. That looks like a failure and is not, which is why the screen says so rather than leaving the person to discover it.
What is not stored
- A turn that fails persists nothing. A turn the user stops is different: the text already delivered is kept and marked interrupted, so the thread after a reload matches what was on screen. See What ends a turn.
Volume — Postgres handles it
"Significant volume" is the normal case for a Message table; the levers:
- Partition
Message(by time, or byagentId/teamId) so hot ranges stay small. - Index for the real queries (
chatId, createdAt); avoid wide scans. - Archive cold conversations to object storage (the row keeps a pointer); the live table stays lean.
- The raw trace/prompt blobs do NOT live here — those go to Debug & tracing. Keeping telemetry out is what keeps this table fast.
Start shared → split when it earns it
- Now: chats live in the main Postgres (one DB, simplest).
- At scale: move the
agent/chatdata to a dedicated Postgres instance when chat write-IO starts competing with transactional load. Because it's behind the gateway, this is a config change, not a rewrite — exactly the "in the api first, separate database later" path.
See also
- Debug & tracing — why prompts/traces stay out of this table.
- Memory — the search index derived from these messages.
- DB schema —
Chat/Messagein the wider model.