No chatbot — and that’s the point
ClickHouse has AI integrated into its products, and the company uses AI well internally. Customers benefit from AI when they use ClickHouse. But there is no end-user AI product: no general assistant that competes with ChatGPT, and no sign of an intention to build one. That is not ClickHouse’s way. It is not here to compete with OpenAI or Anthropic.
The honest edge of that claim is worth stating, because the company has bought a chat interface. When ClickHouse acquired LibreChat in November 2025, the announcement described “a leading open-source AI chat platform that provides a unified interface for interacting with large language models” and framed it as part of an open-source Agentic Data Stack, aimed at teams that deploy agents to “analyze and act on large datasets through natural language”. The post’s showcase is an internal data-warehouse agent, not a consumer product. A chat window is a component there, the thing that sits in front of the database. It is not the product being sold. That distinction is the whole chapter.
Three separate questions run through any discussion of “ClickHouse and AI”, and they are easy to blur:
- Internal adoption. How the company’s own people use AI, and what it measurably improves. ClickHouse’s own account, in How we’re building a data platform for a new user: agents (March 2026), says its internal data-warehouse agent answers roughly 70% of data-warehouse questions for over 200 users.
- Product integration. How AI makes ClickHouse easier or more useful for its customers.
- Infrastructure for AI. How ClickHouse supports the applications and agents that others build.
The first two are largely the company’s own story. The third is where this chapter argues the opportunity lies, and where a practitioner can test the claim with nothing but SQL.
What ClickHouse already ships for AI
Every item below is checked against the vendor’s own documentation or announcement, and where it can
run on the open-source binary, it was run on clickhouse local 26.4.1.
Vector search: vector_similarity and QBit
ClickHouse can store embeddings in an Array(Float32) column and rank rows by cosineDistance,
L2Distance or dotProduct without any index. An approximate-nearest-neighbour index makes that scale.
The documentation on exact and approximate vector search
describes the vector_similarity skip index, an HNSW implementation declared as
INDEX name col TYPE vector_similarity(<type>, <distance_function>, <dimensions>), available from
25.8, with quantization options from f64 down to b1. The same page now also lists a Quantized(...)
codec with several methods. On 26.4 the gating settings (allow_experimental_vector_similarity_index,
enable_vector_similarity_index) are both 1 by default, which I checked in system.settings; the
docs still carry an “experimental” label, so treat the label and the default as two different signals
and read the page before you depend on it. The walk-through of what vectors and distances are lives in
Vector similarity search in ClickHouse; here is
the version an agent’s memory table needs, run end to end:
CREATE TABLE memory
(
id UInt64, agent LowCardinality(String), ts DateTime, note String,
emb Array(Float32),
INDEX ix emb TYPE vector_similarity('hnsw', 'cosineDistance', 4)
) ENGINE = MergeTree ORDER BY id;
INSERT INTO memory VALUES
(1,'planner', '2026-01-01 10:00:00','deploy needs the green build', [0.9, 0.1, 0.0, 0.0]),
(2,'coder', '2026-01-01 10:05:00','tests fail on the parser branch', [0.1, 0.9, 0.1, 0.0]),
(3,'reviewer','2026-01-01 10:09:00','parser fix looks right, merge it', [0.15,0.85,0.2, 0.0]),
(4,'planner', '2026-01-01 10:20:00','release is blocked on a green build',[0.95,0.05,0.0,0.1]);
SELECT id, agent, note, round(cosineDistance(emb, [0.1,0.9,0.1,0.0]), 4) AS d
FROM memory ORDER BY d LIMIT 2;
2 coder tests fail on the parser branch 0
3 reviewer parser fix looks right, merge it 0.0089
Four dimensions is a toy; real embeddings have hundreds or thousands. The point is the shape: the query
is plain SQL, the nearest neighbours come back with their author and timestamp, and
EXPLAIN indexes = 1 reports the ix skip index with vector_similarity as the description, so you
can confirm the planner considered it. With four rows the index prunes nothing, which is expected.
QBit is the other vector idea, and it is stranger. Instead of keeping each vector’s elements
together, it transposes storage by bit position, so one stored column supports a precision chosen
at query time: read fewer bits for less I/O, more bits for accuracy. The
QBit documentation lists the
L2DistanceTransposed, cosineDistanceTransposed and dotProductTransposed functions. Here is the
trade-off on three rows (26.4, allow_experimental_qbit_type = 1):
SET allow_experimental_qbit_type = 1;
CREATE TABLE qb (id UInt32, v QBit(Float32, 4)) ENGINE = MergeTree ORDER BY id;
INSERT INTO qb VALUES (1,[0.9,0.1,0,0]),(2,[0.1,0.9,0.1,0]),(3,[0.15,0.85,0.2,0]);
SELECT id,
round(L2DistanceTransposed(v, [0.1,0.9,0.1,0], 32), 4) AS full_32_bits,
round(L2DistanceTransposed(v, [0.1,0.9,0.1,0], 8), 4) AS first_8_bits
FROM qb ORDER BY id;
1 1.1358 0.9603
2 0 0.41
3 0.1225 0.4001
At 32 bits row 2 is the exact match and row 3 is next. At 8 bits the two are nearly tied (0.41 against 0.4001) and the order flips. That is the cost of the knob, visible in three rows: lower precision buys I/O and loses ranking resolution among close neighbours. For an agent memory, you would use the cheap read to shortlist and the full read to rank the shortlist.
The MCP server
The official ClickHouse MCP server exposes tools to run
SQL, list databases and list tables to any MCP-capable agent, runs queries with readonly = 1, and
includes chDB tools for querying local data. The Agentic Data Stack’s own
repository wires it together with LibreChat and
Langfuse behind one docker compose up. Read-only is a sensible default for analysis and the wrong
default for coordination, which is the subject of the next sections.
chDB
chDB joined ClickHouse in March 2024 and embeds the engine as a library in the host process. ClickHouse now pitches it directly at agents in chDB as the Agent’s Local Data Engine: memory as queryable data rather than ever-larger prompts, append-only revision tracking, and the claim that the real cost of a failing tool call is tokens, because the model re-sends context on retry. That last argument matters for infrastructure design, and the final section returns to it.
Langfuse
Langfuse joined in January 2026. It is an open-source LLM observability, evaluation and prompt-management platform that already stored its data in ClickHouse. It is the closest thing to a precedent for the thesis: traces of what models did, kept in ClickHouse, queried with SQL. It records what a single application’s LLM calls did. It is not a place where several agents talk to each other, which is the gap this chapter is about.
AI functions and client assistance
The September 2026 post AI Functions in ClickHouse
describes SQL functions that call a model per row: aiClassify, aiExtract, aiGenerate and
aiTranslate from 26.4, aiEmbed from 26.6, aiFilter, aiRedact and aiSimilarity from 26.8. They
are in beta, in private preview in ClickHouse Cloud, are configured through named collections holding
provider credentials, and accept OpenAI or any OpenAI-compatible endpoint. The client gained
natural-language SQL generation too (a ?? prefix; see
PR #83314). Both put models inside the database
workflow. Neither is a product that talks to end users.
Two things follow. First, the stack is real and moving quickly; the claim “ClickHouse has AI in its products” needs no hedging. Second, everything above treats the database as something an agent reads: an analytics target, a vector store, a trace sink. The remainder of the chapter asks what changes when the agents also write to each other through it.
Agents as database users
A multi-agent system, whatever it is built from, needs five things from its substrate. None is exotic. All five exist in ClickHouse in some form.
| Need | What the system asks | ClickHouse primitive |
|---|---|---|
| Identity | Who is this agent, what may it touch? | Users, roles, row policies, settings profiles, quotas |
| Messaging | How does one agent tell another something? | An append-only MergeTree table |
| Memory | What do agents remember, and who can recall it? | Tables, vector_similarity, materialized views |
| Task coordination | Who does what, in what state? | Event rows plus an aggregated “current state” view |
| Audit | What did every agent actually do? | system.query_log, log_comment, MV-fed tables |
The framing that makes this useful is the one ClickHouse itself uses: an agent is a new kind of
database user. The model behind it does not matter, since it speaks SQL over HTTP or the native
protocol like any other client. A planner running on one vendor’s model and a coder on another’s share a
bus because both can send a INSERT.
Identity and isolation
One database user per agent, one role for what agents may do, and policies attached to the role:
CREATE ROLE agent_role;
CREATE SETTINGS PROFILE agent_limits
SETTINGS max_execution_time = 30 MAX 60, max_memory_usage = 2000000000 MAX 4000000000
TO agent_role;
CREATE QUOTA agent_quota FOR INTERVAL 1 hour MAX queries = 5000, result_rows = 10000000 TO agent_role;
CREATE USER planner IDENTIFIED WITH sha256_password BY 'change-me';
CREATE USER coder IDENTIFIED WITH sha256_password BY 'change-me';
GRANT agent_role TO planner, coder;
GRANT SELECT, INSERT ON bus.messages TO agent_role;
CREATE ROW POLICY inbox ON bus.messages FOR SELECT
USING recipient = currentUser() OR sender = currentUser() TO agent_role;
These statements ran as written on 26.4 (the SHOW CREATE ROW POLICY and SHOW GRANTS output came
back as expected). What clickhouse local cannot show is the policy filtering per user, because there
is no login to switch between: that needs a server, and EXECUTE AS additionally requires
access_control_improvements.allow_impersonate_user. Three properties are what make this a real
isolation layer rather than a convention:
- The row policy is enforced by the server on every
SELECT. An agent that writesSELECT * FROM bus.messagesgets its own mail, not everyone’s. The agent’s prompt cannot talk the database out of it. - The profile constraints cap what a runaway agent can ask for:
max_execution_timecan be set to 30 seconds with a ceiling of 60, so the agent can lower its own limit but not raise it past the ceiling. A model that writes an accidental cross join pays for it with a timeout, not an outage. - The quota bounds hourly consumption per role. It is a cheap, server-side circuit breaker against an agent stuck in a retry loop, the failure mode the chDB post attaches a cost in model tokens to.
Two grammar corners matter for exactly this design, and both have feature requests behind them. A
“mail slot”, where any agent may write to a table and only the owner may read it, needs a grant to
every principal; ROW POLICY, SETTINGS PROFILE and QUOTA accept TO ALL and GRANT does not
(GRANT cannot say TO ALL). And alias tables that present a stable
surface over a changing table leave it unclear whose row policies apply
(SQL SECURITY for the Alias engine).
Messaging and memory: an append-only log
A message bus is the simplest thing ClickHouse does well. A MergeTree table is an append-only log with
a sort key; consumers read their slice. The demo below uses a messages table and a bus database:
CREATE TABLE bus.messages
(
id UUID DEFAULT generateUUIDv7(), ts DateTime64(3) DEFAULT now64(3),
sender String DEFAULT currentUser(),
recipient String, topic LowCardinality(String), body String
) ENGINE = MergeTree ORDER BY (recipient, ts);
Defaulting sender to currentUser() means the sender is stamped by the server, not claimed by the
agent. With ORDER BY (recipient, ts) an agent’s inbox is a contiguous range on the primary key, so
polling “everything for me since the last timestamp I saw” is cheap however large the table grows.
Persistent memory is the same table with an embedding column and the vector_similarity index from
the previous section: the memory example above is already the recall query.
A controlled interface is a view that runs with its definer’s rights. An agent that has no grant on the table can still read a curated slice through it:
CREATE VIEW bus.inbox_coder DEFINER = coder SQL SECURITY DEFINER AS
SELECT ts, sender, topic, body FROM bus.messages WHERE recipient = 'coder';
The view works on 26.4 with DEFINER and SQL SECURITY DEFINER as shown. Use it to expose
read-only projections, redactions or aggregates, and keep the base tables out of the agents’
grants. A parameterised version, where the view takes the recipient as a parameter, is covered in
Global parameters with a view.
Task coordination: events, not rows to update
A task queue in a mutable-row database is a table with a status column and UPDATE ... WHERE status = 'open'. ClickHouse inverts the problem: do not update, append. A task is a stream of events, and its
current state is an aggregate over them. A materialized view is the reaction that turns messages into
tasks:
CREATE TABLE bus.task_events
(
task_id UUID, ts DateTime64(3) DEFAULT now64(3), agent String,
state Enum8('open' = 1, 'claimed' = 2, 'done' = 3, 'failed' = 4),
detail String DEFAULT ''
) ENGINE = MergeTree ORDER BY (task_id, ts);
CREATE MATERIALIZED VIEW bus.mv_open_task TO bus.task_events AS
SELECT id AS task_id, ts, sender AS agent, 'open' AS state, body AS detail
FROM bus.messages WHERE topic = 'task';
CREATE VIEW bus.tasks AS
SELECT task_id,
argMax(state, ts) AS current_state,
argMinIf(agent, (ts, agent), state = 'claimed') AS owner,
anyIf(detail, state = 'open') AS what
FROM bus.task_events GROUP BY task_id;
Insert three messages, one of them chat rather than a task, and the materialized view opens exactly two
tasks (open 2 in my run). The reaction fires in the insert’s own pipeline, with no scheduler, no
consumer process and nothing to keep alive. For work that needs a clock instead of an event, a
refreshable materialized view is the equivalent. The
fundamentals of how views behave on insert are in Materialized views basics.
Audit: system.query_log as the flight recorder
Because every agent is a user, every statement it runs is attributed. system.query_log carries the
user, duration, rows written, exception code and a free-text log_comment, and it is the audit trail you
did not have to build:
SELECT user, log_comment, count() AS queries,
countIf(exception_code != 0) AS failed,
quantile(0.95)(query_duration_ms) AS p95_ms,
sum(written_rows) AS rows_written
FROM system.query_log
WHERE type != 'QueryStart' AND event_date = today()
GROUP BY user, log_comment ORDER BY queries DESC;
This is server-only (clickhouse local has no query log; I syntax-checked it with clickhouse format
and left it untested). Agents should set log_comment to a task identifier on every call, so a failure
is traceable to a task and not only to an account. That is the workaround for a gap with its own
request, tags on every logged query: a free-text field works,
but a structured one would not need parsing. Existing recipes such as
which queries fail most apply unchanged once the agents are users.
Where it hits walls
The mapping above is not the full picture, and the places where it strains are the interesting part. All of them are consequences of what ClickHouse is: an analytical database with parts, merges and no transaction manager on the hot path.
A write cannot answer
Until 26.11 there is no INSERT ... RETURNING; on 26.4 the parser rejects it, because after VALUES
the rest of the byte stream is data, so there is no statement end to hang a clause on. 26.11 adds
INSERT … RETURNING (SELECT …), placed before the data and run over the stream of inserted rows, so an
agent can get back the generated id and defaults of its own message in the same round trip. What it
still cannot learn from the insert is whether a downstream materialized view succeeded: the
RETURNING query does not see what the view writes (tested on a 26.11 development build). The full story, including the three issues (the 26.11 change closed two of them; the original request is still open) and the
architecture the absence forces, is The write that cannot answer.
The practical consequences for a bus:
- Generate identifiers on the client (a UUID the sender chooses), or on 26.11 and later take the
server’s
generateUUIDv7()default back throughRETURNING, when the sender needs to refer to its own message. - Treat “did my message produce its effect?” as a read: the sender polls a completion row or a view.
No row locks, no claim transaction
The classic queue primitive is SELECT ... FOR UPDATE SKIP LOCKED. ClickHouse has neither row locks nor
multi-statement transactions suitable for it. Two agents that both see an open task will both try to
take it, and both inserts will succeed. The event-log design above handles this by moving the decision
to read time. I ran exactly that race: two agents inserted a claimed event for the same task, and the
tasks view resolved it deterministically.
-- both inserts succeed; the winner is decided on read: earliest claim, ties by agent name
SELECT argMinIf(agent, (ts, agent), state = 'claimed') AS owner,
countIf(state = 'claimed') AS claims
FROM bus.task_events GROUP BY task_id HAVING claims > 0;
coder-1 2
Two claims were recorded and one owner came out. The loser learns it lost only by reading back: the
protocol is claim, then check owner, then proceed only if it equals you. That is optimistic
concurrency by construction and it is sound, but it has costs. The loser did a wasted insert; tie-breaking
on timestamps assumes reasonably synchronised clocks, and the (ts, agent) tuple only guarantees
a deterministic answer, not a fair one; and a claim is not a lease, so an agent that dies after
claiming leaves the task stuck until some other process appends a timeout event. On a replicated
cluster the read-back must see the other replica’s claim, which means paying for
insert_quorum on the write and select_sequential_consistency on the read; both settings default to
0 on 26.4, which I checked in system.settings. If you need genuine exactly-once task handoff, the honest
answer is a small coordination service, and this chapter’s question is how much of the system can stay in
SQL before you need one. For updating the current state of an entity in place rather than by events, the
upsert request is the relevant gap: a unique-key mechanism
does not exist.
Polling, because nothing pushes
ClickHouse has no LISTEN/NOTIFY. LIVE VIEW was the nearest thing: an experimental view you could
WATCH for pushed changes. It was removed in 25.11, and on 26.4 CREATE LIVE VIEW is a syntax error
(the history is in Live View, but tell me what changed). What
remains is polling a keyed range, which the sort key makes cheap, or reacting to an insert with a side
effect such as calling a webhook from an insert. The poll
interval is a floor on coordination latency. Agents that poll every second add a second to every hop;
agents that poll every 50 ms add load. Neither is wrong; both are a choice you should measure rather than
guess.
Small inserts, many parts
Agents talk in small messages, and every INSERT creates a part. I counted it on the demo: one message
insert gave messages 1 part, and the three event inserts gave task_events 3, before a single merge.
An agent fleet that writes one row per message will hit Too many parts long before it hits any
throughput limit. The fix is the standard one: batch where you can, and use async inserts where you
cannot. They are on by default in the 26.4 settings I read (async_insert = 1, wait_for_async_insert = 1).
Waiting for the flush gives you a durability guarantee at the cost of latency, a trade-off covered in
Async inserts explained and Insert batching and too many parts.
For a bus, wait_for_async_insert = 1 is the right default: an agent that believes its message was sent
when it was only buffered is a worse failure than one that waited 200 ms.
Statements as data
Agents often want to generate a statement and then run it. Today that is two steps, with the second
done by the client. The request for an in-SQL evaluator (Asking ClickHouse for EVAL)
was partially answered by an eval table function in 26.7. Treat generated SQL as untrusted input to a
governed interface, not as something to run in the agent’s own session.
How to measure whether it works
A coordination layer is only worth building if it can be judged. Five measurements cover it, and every one can be answered from tables the system already writes.
| Measure | Question | Where it comes from |
|---|---|---|
| Adoption | How many agents and models use the bus, and how many messages per task? | uniq(sender), count() per topic in bus.messages |
| Reliability | What share of tasks reach done, and how often do claims collide or time out? |
bus.tasks by current_state; claims > 1 rate from the claim query |
| Coordination latency | How long from open to claimed, and from claimed to done? |
task_events.ts differences, per hop; p50 and p95, not the mean |
| Cost per completed task | Tokens, queries and compute per finished task | system.query_log by log_comment, joined to model usage recorded as events |
| Developer effort | How much code does an agent need to join? | Lines in the client, count of distinct statements, time to first message |
Latency is where the polling and batching choices from the previous section show up, so measure it per hop. Reliability needs a definition that includes the quiet failures: a task that was claimed and never completed is a failure that raises no exception. Cost is the one that most often decides whether the design survives. The token argument in the chDB post is the right instinct: a coordination failure that makes a model re-read its whole context costs far more than the SQL that caused it, so the cheapest reliability improvement is usually a clearer, faster answer to “did my write take effect?”.
Developer effort is the honest counterweight. Every wall above is a workaround an agent author has to
know about: read-back instead of RETURNING, claim-then-check instead of locks, batching instead of
single inserts. A ClickHouse-based bus that needs a page of conventions to use correctly is a product
only if those conventions are packaged, as views, roles and a thin client, so that an agent sees
send, claim and complete and never argMinIf.
What belongs in ClickHouse, and what does not
The division follows from the walls. ClickHouse is strong where the problem is append, aggregate, audit, attribute and recall: the message log, the task event history, the memory with vector recall, the per-agent accounting and the isolation rules enforced by a server rather than a prompt. It is weak where the problem is decide exactly once, right now, for one entity: leases, locks, heartbeats and exactly-once handoff. A pragmatic design keeps the first group in SQL and delegates only the second to a small coordinator, or accepts optimistic claim-and-check for the workloads where a duplicated unit of work is cheap.
This is also why the thesis fits the company. ClickHouse does not have to build the agents, the chat window or the model. It has to make the shared substrate they write into fast, governed and queryable. The vendor is moving the same way: its March 2026 post commits to native RAG APIs, semantic-search tools for agents and one-click MCP connections, and the Agentic Data Stack is a deployment of exactly that database-in-the-middle idea. What the stack does not yet offer is the other direction, agents writing to each other. That is the opportunity this chapter describes, and the walls above are the work that remains.
Sources
- ClickHouse acquires LibreChat (2025-11-04)
- ClickHouse acquires Langfuse (2026-01-16)
- How we’re building a data platform for a new user: agents (2026-03-27)
- AI Functions in ClickHouse (2026-09-11)
- chDB as the Agent’s Local Data Engine and chDB joins the ClickHouse family
- Exact and approximate vector search and QBit
- mcp-clickhouse and agentic-data-stack
- PR #83314, AI-powered SQL generation in the client
Discussion
Comments live in GitHub Discussions. Sign in with GitHub to join in.