A user query is not a standalone question but a message in a conversation, and the conversation belongs to Query Engine: it remembers history, carries context between messages, and holds the session. Yet the turn is driven by the user's chat model — it holds the knowledge-base search tool and decides, on each message, whether to answer on its own or reach into the store. Every surface — Web App, Slack, Telegram, Mattermost, MCP, extension — is just a thin window onto one conversation model; the state lives here, in its own tables (conversations · messages), not on the client.
A conversation is stateful: the session groups the steps of one exchange, and history accumulates message by message. That state has to live somewhere — and it lives with Query Engine, the only module that sees both the query and the assembled answer. Its data home is its own tables: sessions and messages with their answers.
A conversation is a durable thread, not an ephemeral
login session: it lives until deleted and does not expire on idle
(a timeout is about signing in, not about the conversation). The line
between a “new” conversation and a “continuation” is drawn not by a
separate command but by the presence or absence of a reference
to the conversation (conversation_id) in the
request: a message with the reference appends to the existing thread, a
message without it opens a new one.
A conversation is created lazily — on the first message: we never spin up empty threads; the conversations row is born when the first message is sent, and its identifier is returned to the client for subsequent turns. The “New chat” button opens nothing on the server — it merely clears the active reference on the client, and the new conversation is born by the first message actually sent.
Hence a single lever across all channels: “new chat”, “clear”, and “resume” are not separate API operations but expressions of the same “which reference the client sends”. What counts as a new conversation is up to the surface:
/new command resets the active reference, and the next
message starts a new conversation (intercepted on the surface side,
never recorded in history).
Conversation management — listing, renaming, deleting, retention —
lives in the Web App shell and is designed alongside it; the data model
is ready for it (the cascade runs off conversations). Here
we set only the rule for where the boundary of one exchange falls.
Not every message is a query to the base. “Thanks”, “rephrase this
paragraph”, “what even is an OKR” — ordinary conversation that needs no
store. But “what's the deadline for project Ajax” is a question for
company knowledge. So the turn is driven by the
chat model: it holds a toolset —
search_knowledge (when the base is
non-empty) and, if the admin enabled it,
web_search — and it decides, on each
message, whether to answer on its own or reach for them. Conversation →
it answers without search and without citations. A question for
knowledge → it calls the tools — several at once if needed, in
a single round — and builds the answer on what they
return.
search_knowledge · web_search
search_knowledge ± web_search)
answer strictly from what was found ·
with a citation to the source
Bias toward searching. On the edge between “is this
chit-chat or a query”, the model leans toward searching: an empty
search is cheap, while a made-up company fact is expensive — it breaks
trust. This holds when there is something to search: with an empty base,
search_knowledge is not offered to the model at all
(derived from
is_empty), the bias does not apply, and the model
answers from general knowledge like an ordinary assistant. What
grounding means in each mode and how an honest “not found” sounds is
held by
grounding. Which
tools the chat gets at all is up to the admin:
web_search is off by default, and a disabled tool is not
offered to the model
(tool catalog).
Where a single round of tools ends and a full agent loop begins is at
the boundary with Agent Engine.
Within a session, messages are linked: “and what about his timeline?”
only makes sense against the previous message. So, having decided to
search, the model formulates a self-contained query —
understandable without the history — and passes it as an argument to
search_knowledge. There is no separate rewriting step: the
model already has the conversation history in view, so it fills in the
omitted subject itself, in the same move that decides to call search.
Without this, “and more?” would go to retrieval as “more” — the vector
would catch the phrase, not the topic of the conversation.
What happens on a tool call — embed, search under access rights, packing, grounding generation — is held by the search route. How many past turns reach the model in a long exchange is a matter of the window budget.
The chat model's window is not boundless: everything it sees on one turn shares a common token cap. On a grounding turn, the system instructions, the conversation history, the current message, and the retrieved context all land in it at once — and room must still be left for the answer itself. So overflow is not “history too long” but competition for a single budget: two consumers grow independently, yet are trimmed under one cap.
search_knowledge description
fixed
Trimming the window is not losing memory. The verbatim transcript lives in messages, retrieval snapshots in retrieval_trace, and the answer's facts are re-retrieved by a fresh retrieval from Knowledge Store on every grounding turn. The window is a working buffer for one call, not storage: what falls out of it stays in the base. That is why history here can be trimmed more boldly than in a pure chatbot, where it is the only memory.
v2 — a running summary of old turns: when even the trimmed history stops fitting, the earliest turns are collapsed by a separate call into a brief summary, and the window carries “summary + the last verbatim window”. A layer on top of the token-window, not a replacement: it adds memory depth at the cost of an extra call and the risk of losing a low-frequency but important detail across repeated compressions.
There are several entry points into the conversation — Web App Slack Telegram Mattermost Browser Extension — and all of them are thin clients: they render messages and stream the answer, but carry no conversation logic. They share one conversation model through a single internal Query Engine API. Sessions, history, context carry, the decision to search — all on the Query Engine side; the surface only opens a session, sends a message, and displays the answer.
This keeps the different channels from diverging in behavior: the conversation rule is implemented once, not copied into each client. A new surface plugs into the same API and gets the same conversation memory for free — including a customer's custom surface through the public contract. → Public API
MCP does not belong here. It is a surface of a different kind — not a conversation client but a supplier of a search tool to an external model: it holds not a conversation but a tool-call, and returns findings, not a finished answer. → MCP
The answer is delivered synchronously, as a stream: the tokens of the grounding answer flow to the surface as they are generated, without queuing a task.
Slack is the exception. The messenger does not hold the connection and expects an answer within 3 seconds: the stream is assembled on the server and returned as a post in the thread — receipt is acknowledged immediately, processing runs asynchronously through a queue. → Slack
Every answer carries a lightweight rating: thumbs up / down. This is not an action on the conversation but a quality signal: was the answer good or off the mark. The rating sits next to the message itself in the conversation store — we collect it for the future, to gauge answer quality and tune ranking.