|
| 1 | +--- |
| 2 | +title: "ClickHouse chat agent" |
| 3 | +sidebarTitle: "ClickHouse chat agent" |
| 4 | +description: "Build a chat agent that answers questions about your data by writing and running SQL against ClickHouse Cloud, using chat.agent() and the ClickHouse Node.js client." |
| 5 | +--- |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +This example is a [chat agent](/ai-chat/overview) that answers natural-language questions about the data in a [ClickHouse Cloud](https://clickhouse.com/cloud) database. The agent discovers the schema, writes ClickHouse SQL, runs it through the official [ClickHouse Node.js client](https://clickhouse.com/docs/integrations/javascript), and streams back answers with markdown tables. Trigger.dev handles the chat session, turn loop, streaming, and resumability — the whole agent is one `chat.agent()` call and three tools. |
| 10 | + |
| 11 | +**Tech stack:** |
| 12 | + |
| 13 | +- **[Trigger.dev AI chat](/ai-chat/overview)** for the agent session, turn loop, and streaming |
| 14 | +- **[ClickHouse Node.js client](https://clickhouse.com/docs/integrations/javascript)** (`@clickhouse/client`) for queries over HTTPS |
| 15 | +- **[AI SDK](https://ai-sdk.dev/)** with Anthropic Claude for the model and tool calling |
| 16 | + |
| 17 | +**Features:** |
| 18 | + |
| 19 | +- **Schema discovery tools**: `listTables` reads table names, engines, and row counts from `system.tables`; `describeTable` returns column names and types using a bound `Identifier` query param, so table names are never interpolated into SQL strings |
| 20 | +- **Read-only query tool**: `runQuery` accepts SELECT-style statements only, enforced in code and backed by ClickHouse settings — `readonly=2`, a 1,000-row result cap, and a 30 second execution timeout |
| 21 | +- **Self-correcting SQL**: query errors are returned to the model as tool output, so the agent reads the ClickHouse error, fixes its SQL, and retries |
| 22 | +- **Single environment variable**: the ClickHouse connection is one `CLICKHOUSE_URL` with the credentials embedded, set in the Trigger.dev dashboard |
| 23 | + |
| 24 | +## GitHub repo |
| 25 | + |
| 26 | +<Card |
| 27 | + title="View the ClickHouse chat agent repo" |
| 28 | + icon="GitHub" |
| 29 | + href="https://github.com/triggerdotdev/examples/tree/main/clickhouse-chat-agent" |
| 30 | +> |
| 31 | + Click here to view the full code for this project in our examples repository on GitHub. You can |
| 32 | + fork it and use it as a starting point for your own project. |
| 33 | +</Card> |
| 34 | + |
| 35 | +## How it works |
| 36 | + |
| 37 | +### The agent |
| 38 | + |
| 39 | +The agent is defined with [`chat.agent()`](/ai-chat/overview). Tools are declared on the config so tool results survive history re-conversion across turns, and the `run` function returns a `streamText()` call: |
| 40 | + |
| 41 | +```ts trigger/clickhouse-agent.ts |
| 42 | +import { chat } from "@trigger.dev/sdk/ai"; |
| 43 | +import { anthropic } from "@ai-sdk/anthropic"; |
| 44 | +import { stepCountIs, streamText } from "ai"; |
| 45 | + |
| 46 | +export const clickhouseAgent = chat.agent({ |
| 47 | + id: "clickhouse-agent", |
| 48 | + idleTimeoutInSeconds: 300, |
| 49 | + tools: { listTables, describeTable, runQuery }, |
| 50 | + run: async ({ messages, tools, signal }) => { |
| 51 | + return streamText({ |
| 52 | + // Spread chat.toStreamTextOptions() FIRST — it wires up |
| 53 | + // prepareStep (compaction, steering, background injection), |
| 54 | + // the system prompt set via chat.prompt(), and telemetry. |
| 55 | + ...chat.toStreamTextOptions(), |
| 56 | + model: anthropic("claude-opus-4-8"), |
| 57 | + system: SYSTEM_PROMPT, |
| 58 | + messages, |
| 59 | + tools, |
| 60 | + stopWhen: stepCountIs(15), |
| 61 | + abortSignal: signal, |
| 62 | + }); |
| 63 | + }, |
| 64 | +}); |
| 65 | +``` |
| 66 | + |
| 67 | +The system prompt tells the agent to explore the schema before querying, write ClickHouse SQL (not Postgres dialect), prefer aggregations, and present results as markdown tables. |
| 68 | + |
| 69 | +### The query tool |
| 70 | + |
| 71 | +`runQuery` guards against writes twice: a statement allowlist in code, and ClickHouse settings on the request itself. Errors are returned to the model instead of thrown, which is what makes the agent self-correct: |
| 72 | + |
| 73 | +```ts trigger/clickhouse-agent.ts |
| 74 | +const READ_ONLY_STATEMENTS = /^\s*(select|with|show|describe|desc|explain|exists)\b/i; |
| 75 | + |
| 76 | +const runQuery = tool({ |
| 77 | + description: |
| 78 | + "Run a read-only SQL query against ClickHouse and get the results as JSON rows.", |
| 79 | + inputSchema: z.object({ |
| 80 | + query: z.string().describe("The ClickHouse SQL query to run"), |
| 81 | + }), |
| 82 | + execute: async ({ query }) => { |
| 83 | + if (!READ_ONLY_STATEMENTS.test(query)) { |
| 84 | + return { error: "Only read-only statements are allowed." }; |
| 85 | + } |
| 86 | + try { |
| 87 | + const result = await getClickHouse().query({ |
| 88 | + query, |
| 89 | + format: "JSONEachRow", |
| 90 | + clickhouse_settings: { |
| 91 | + // readonly=2: reads only (no writes/DDL), but per-query settings |
| 92 | + // like the limits below are still allowed. |
| 93 | + readonly: "2", |
| 94 | + max_result_rows: "1000", |
| 95 | + result_overflow_mode: "break", |
| 96 | + max_execution_time: 30, |
| 97 | + }, |
| 98 | + }); |
| 99 | + const rows = await result.json(); |
| 100 | + return { rowCount: rows.length, rows }; |
| 101 | + } catch (error) { |
| 102 | + // Return ClickHouse errors to the model so it can fix the query and retry. |
| 103 | + return { error: error instanceof Error ? error.message : String(error) }; |
| 104 | + } |
| 105 | + }, |
| 106 | +}); |
| 107 | +``` |
| 108 | + |
| 109 | +### Connecting to ClickHouse |
| 110 | + |
| 111 | +The client reads a single `CLICKHOUSE_URL` environment variable — the HTTPS endpoint with credentials embedded — set in the Trigger.dev dashboard on the [Environment Variables page](/deploy-environment-variables): |
| 112 | + |
| 113 | +```bash |
| 114 | +CLICKHOUSE_URL=https://default:YOUR_PASSWORD@YOUR_SERVICE.clickhouse.cloud:8443 |
| 115 | +``` |
| 116 | + |
| 117 | +```ts trigger/clickhouse-agent.ts |
| 118 | +import { createClient } from "@clickhouse/client"; |
| 119 | + |
| 120 | +const clickhouse = createClient({ url: process.env.CLICKHOUSE_URL }); |
| 121 | +``` |
| 122 | + |
| 123 | +### Chatting with the agent |
| 124 | + |
| 125 | +Run `npx trigger.dev@latest dev`, then open the **AI agents** page in the dashboard and chat with `clickhouse-agent` in the playground. With a dataset like [NYC Taxi](https://clickhouse.com/docs/getting-started/example-datasets/nyc-taxi) loaded, asking "What were the top 5 busiest pickup days?" produces a `listTables` call, a `describeTable` call, a SQL aggregation, and a streamed markdown table of results. |
| 126 | + |
| 127 | +## Relevant code |
| 128 | + |
| 129 | +- **Agent + tools**: [trigger/clickhouse-agent.ts](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/trigger/clickhouse-agent.ts): the `chat.agent()` definition, the three tools, the read-only guards, and the ClickHouse client |
| 130 | +- **Trigger config**: [trigger.config.ts](https://github.com/triggerdotdev/examples/blob/main/clickhouse-chat-agent/trigger.config.ts): project config pointing at the `trigger/` directory |
| 131 | + |
| 132 | +## Learn more |
| 133 | + |
| 134 | +<CardGroup cols={2}> |
| 135 | + <Card title="AI chat overview" icon="message-bot" href="/ai-chat/overview"> |
| 136 | + How chat agents, sessions, and the turn loop work. |
| 137 | + </Card> |
| 138 | + <Card title="Tools" icon="wrench" href="/ai-chat/tools"> |
| 139 | + Declaring tools on your agent and how they persist across turns. |
| 140 | + </Card> |
| 141 | +</CardGroup> |
0 commit comments