docs/guides/example-projects/clickhouse-chat-agent.mdx
This example is a fullstack chat agent that answers natural-language questions about the data in a ClickHouse Cloud database — and presents the answers as interactive charts, tables, stat cards and maps instead of walls of text. The agent discovers the schema, writes ClickHouse SQL, runs it through the official ClickHouse Node.js client, then calls a renderVisualization tool with a json-render spec that a Next.js chat UI renders live with shadcn/ui components.
Tech stack:
@clickhouse/client) for queries over HTTPSuseChat on the frontend@json-render/shadcn component library for generative UIuseTriggerChatTransport — the browser talks directly to Trigger.dev, no API route to maintainFeatures:
renderVisualization tool takes a json-render spec — bar/line/area/pie charts, data tables, stat-card KPI rows and point maps, composed in cards and grids — with the query results inlined. Specs are validated against the component catalog and errors are returned to the model, so it corrects the spec and retries.prompts.define(), resolvable per-run, overridable from the dashboard without redeploying — and storing it via chat.prompt.set() wires up experimental_telemetry, so every model call appears in the run trace with token, cost and latency metricslistTables reads table names, engines and row counts from system.tables; describeTable returns column names and types using bound Identifier query params, so table names are never interpolated into SQL stringsrunQuery 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<Card title="View the ClickHouse chat agent repo" icon="GitHub" href="https://github.com/triggerdotdev/examples/tree/main/clickhouse-chat-agent"
Click here to view the full code for this project in our examples repository on GitHub. You can fork it and use it as a starting point for your own project. </Card>
The agent is defined with chat.agent(). The system prompt is a versioned AI Prompt: the editable analyst guidance lives in the prompt template, while the json-render component reference is generated from the catalog at run time and injected as a template variable. Storing the resolved prompt with chat.prompt.set() lets chat.toStreamTextOptions() supply the system text, model, config and telemetry:
import { prompts } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { anthropic } from "@ai-sdk/anthropic";
import { createProviderRegistry, stepCountIs, streamText } from "ai";
import { z } from "zod";
import { catalogPromptSection } from "../lib/catalog";
const registry = createProviderRegistry({ anthropic });
const systemPrompt = prompts.define({
id: "clickhouse-analyst",
model: "anthropic:claude-opus-4-8",
variables: z.object({ componentReference: z.string() }),
content: `You are a ClickHouse data analyst. ...
## renderVisualization spec reference
{{componentReference}}`,
});
export const clickhouseAgent = chat.agent({
id: "clickhouse-agent",
idleTimeoutInSeconds: 300,
// Declared on the config so tool results survive history re-conversion across turns
tools: { listTables, describeTable, runQuery, renderVisualization },
onChatStart: async () => {
// Latest prompt version (or an active dashboard override), with the
// component reference generated from the catalog so it always matches
// the deployed code.
const resolved = await systemPrompt.resolve({
componentReference: catalogPromptSection(),
});
chat.prompt.set(resolved);
},
run: async ({ messages, tools, signal }) => {
return streamText({
// Fallback model only — placed BEFORE the spread so the stored
// prompt's model (including dashboard overrides) wins when set.
model: anthropic("claude-opus-4-8"),
// Wires up prepareStep (compaction, steering, background injection),
// plus the system prompt + model + config + telemetry from chat.prompt().
...chat.toStreamTextOptions({ registry }),
messages,
tools,
stopWhen: stepCountIs(15),
abortSignal: signal,
});
},
});
A single module defines which components the model may use: Table, Card, Grid, Badge and friends from @json-render/shadcn, plus custom chart components (shadcn charts on Recharts), a Stat card, and a PointMap built on mapcn. The same catalog produces the system-prompt reference and validates tool calls:
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
export const catalog = defineCatalog(schema, {
components: {
// Layout & text from the stock shadcn catalog
Card: shadcnComponentDefinitions.Card,
Grid: shadcnComponentDefinitions.Grid,
Table: shadcnComponentDefinitions.Table,
// ...plus custom BarChart, LineChart, AreaChart, PieChart, Stat, PointMap
},
actions: {},
});
// Generates a component reference (props as JSON schema, from the same zod
// definitions) for the system prompt — the prompt can't drift from the code.
export function catalogPromptSection(): string {
/* ... */
}
// Validates a spec against the catalog; errors are phrased for the model
// to correct and retry.
export function validateSpec(spec: VisualizationSpec) {
/* ... */
}
The renderVisualization tool accepts a flat json-render spec with the data rows inlined from earlier runQuery results. Validation failures go back to the model as tool output:
const renderVisualization = tool({
description:
"Render charts, tables and stat cards for the user, instead of describing data as text.",
inputSchema: z.object({
spec: z.object({
root: z.string(),
elements: z.record(
z.string(),
z.object({
type: z.string(),
props: z.record(z.string(), z.unknown()),
children: z.array(z.string()).optional(),
})
),
}),
}),
execute: async ({ spec }) => {
const result = validateSpec(spec);
if (!result.ok) {
// The model reads these, fixes the spec, and calls the tool again
return { ok: false, errors: result.errors };
}
return { ok: true, note: "Rendered to the user. Add at most a one-sentence takeaway." };
},
});
The frontend uses useChat with useTriggerChatTransport — the browser subscribes to the session's streams directly, authenticated by two small server actions. renderVisualization tool parts in the message stream render through json-render's <Renderer> with the shadcn component registry:
"use client";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { clickhouseAgent } from "@/trigger/clickhouse-agent";
import { mintChatAccessToken, startChatSession } from "@/app/actions";
export function Chat() {
const transport = useTriggerChatTransport<typeof clickhouseAgent>({
task: "clickhouse-agent",
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
});
const { messages, sendMessage, stop, status } = useChat({ transport });
// Render text parts as markdown; render tool-renderVisualization parts
// with json-render's <Renderer spec={...} registry={registry} />
}
The registry maps every catalog component to its React implementation — the stock @json-render/shadcn components plus the custom charts and map:
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Table: shadcnComponents.Table,
// ...
BarChart: ({ props }) => <BarChartView {...props} />,
PointMap: ({ props }) => <PointMapView {...props} />,
},
});
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:
const READ_ONLY_STATEMENTS = /^\s*(select|with|show|describe|desc|explain|exists)\b/i;
const runQuery = tool({
description: "Run a read-only SQL query against ClickHouse and get the results as JSON rows.",
inputSchema: z.object({
query: z.string().describe("The ClickHouse SQL query to run"),
}),
execute: async ({ query }) => {
if (!READ_ONLY_STATEMENTS.test(query)) {
return { error: "Only read-only statements are allowed." };
}
try {
const result = await getClickHouse().query({
query,
format: "JSONEachRow",
clickhouse_settings: {
// readonly=2: reads only (no writes/DDL), but per-query settings
// like the limits below are still allowed.
readonly: "2",
max_result_rows: "1000",
result_overflow_mode: "break",
max_execution_time: 30,
},
});
const rows = await result.json();
return { rowCount: rows.length, rows };
} catch (error) {
// Return ClickHouse errors to the model so it can fix the query and retry.
return { error: error instanceof Error ? error.message : String(error) };
}
},
});
The example needs CLICKHOUSE_URL and ANTHROPIC_API_KEY set in the Trigger.dev dashboard on the Environment Variables page, and TRIGGER_PROJECT_REF plus TRIGGER_SECRET_KEY in the local .env for the Next.js server actions:
TRIGGER_PROJECT_REF=proj_xxxxxxxxxxxxxxxxxxxxxxxx
TRIGGER_SECRET_KEY=tr_dev_xxxxxxxxxxxxxxxxxxxxxxxx
Run the agent and the app in two terminals, then open http://localhost:3000:
pnpm dev:trigger # the agent
pnpm dev # the Next.js app
With a dataset like NYC Taxi loaded, asking for a dashboard of daily trip volume, hourly demand and revenue by payment type produces a stat-card KPI row, two charts and a pie in one composed card — and asking "Where do trips start and end?" produces two interactive maps with size-scaled markers.
chat.agent() definition, the versioned prompt, the four tools, the read-only guards, and the ClickHouse clientuseChat + useTriggerChatTransport, message parts, and visualization rendering