Create-or-update-artifactCLARIFY_MCP_CREATE_OR_UPDATE_ARTIFACT
Create a new artifact or write a new version to an existing artifact. An
artifact is a named document with versioned content (e.g. a report, a plan,
a draft) that the user can view and revisit over time.
## When to use this tool
Pick the surface that fits the ask:
- Answer inline in the chat reply for a single fact, number, or short answer.
Do NOT create an artifact for a quick factual lookup. A qualifier like
"quick" or "simple" on a report or dashboard ask describes the effort, not
the format, so that ask still gets an artifact.
- Create an artifact whenever the user wants a durable, revisitable
deliverable — a report, dashboard, analysis, breakdown, summary, overview,
deck, or plan, even when they never say the word "artifact". Reach for one
especially for a multi-part deliverable, or for current-state / snapshot
data. Artifacts open at their own URL and the user revisits them, so produce
one instead of dumping the whole thing into the chat transcript.
- If the deliverable is really just a browsable set of records of a single
object type (columns are direct fields, no aggregation or charts), a Clarify
list via `create-or-update-list` fits better than an artifact.
When the user asked for a report or analysis but a list would serve them,
offer the list and explain the distinction first; if they explicitly asked
for a list, just build it.
## Referring to it in chat
When you mention the result to the user, name it by what it is — a dashboard,
report, sales document, or plan. Do not call it an "artifact" unless the user
used that word first.
## Requirements and prerequisites
Complete all four steps before you call this tool with new SQL. Every step is
required.
1. Read the "artifact-docs" context with
`read-context`, and complete every step it requires. It names
any further context to read, because which ones exist depends on the
workspace.
2. Run every query before you put it in `content`. Prove the independent ones
together — emit them as one batch of parallel tool calls in a single turn,
not one query per turn. Only a query that needs an earlier query's result
stays serial.
3. State any definition you resolved yourself, and ask the user whether to save
it.
4. Call `get-artifact-component-doc` once. Pass every
`@clarify/ui/*` component this version uses in one `specifiers` list.
Call it every time, even for a component you used before in this
conversation, or one you already know. Docs can change between calls,
within one conversation or overnight. Do not trust memory from earlier
in this conversation for current props. The one-line summaries below
help you pick a component. They do not give its props.
Never guess a stage name, a date field, or a metric definition. Never put a
query in an artifact that you have not run. Never end the turn holding an
unsaved definition you have not offered to save. Never use a component's
props from memory or from its one-line summary. Always call
`get-artifact-component-doc` first. No exceptions.
## How it works
Omit `artifact_id` to create a new artifact (`name` is required in this
case). Pass an existing `artifact_id` to write a new version onto that
artifact instead.
Supply the new version in one of two ways — pass `content` or `edits`,
never both:
- `content` — the full JSX source. Use it to create an artifact, or to fully
replace an existing version.
- `edits` — a list of targeted `{ old_string, new_string }` replacements
applied to the currently-saved source on the server. Prefer this to update
an existing artifact, especially a large one: you send only the changed
regions, so the update is smaller and faster and never has to re-emit the
whole file. Read the saved source first with
`get-artifact-source` and copy each `old_string` exactly,
whitespace included; each must match once, or set `replace_all` to replace
every occurrence.
When updating an artifact you did not just author in this conversation, first
read its current source with `get-artifact-source` so you patch
what is actually saved — and while you are there, fold any clear upgrade it
flags into the same version (a hand-rolled pattern a current component now
covers, or stale query and chart code), and tell the user what you improved.
Set `type` to "Dashboard" when the content you authored is an interactive
dashboard; leave it unset for other kinds of library item.
## Authoring the content (JSX)
`content` is the JavaScript/JSX source for ONE React component — not an HTML
document. Clarify transpiles it (Babel, automatic JSX runtime) and mounts it in
a sandboxed iframe; the scaffold owns the HTML shell, the theme, React/ReactDOM,
and the mount call. Your source must:
- Export a component named `ArtifactContents` (a default export also works).
- Import what it uses from the provided modules: `react` (v19.2.8) (hooks),
`recharts` (v3.8.1) (charts ONLY), `framer-motion` (v12.40.0) (animation —
import `m`, not `motion`; the scaffold already wraps the mount in
`LazyMotion`), and the Clarify design-system components below
(`@clarify/ui/*` — chrome, layout, controls). JSX is supported — do not add a
build step or import anything else.
- Prefer the design-system components for structure and text (cards, buttons,
the `@clarify/ui/text` typography set); reach for bespoke components and/or
standard HTML elements only for what they don't cover.
- Render ALL text through the `@clarify/ui/text` components (`Heading1`–`Heading4`,
`Text`, `CaptionText`, …) — not raw `<p>`/`<span>`/`<h*>` and not Tailwind
text utilities (`text-lg`, `font-semibold`, `text-gray-500`) — so type and
color stay on the design scale.
- Use Tailwind utility classes for LAYOUT ONLY — the theme ships the standard
layout, spacing, and sizing utilities (e.g. `className="flex flex-col gap-4 p-4"`).
For a one-off color use the theme's CSS custom properties inline
(e.g. `style={{ color: 'var(--color-brand-500)' }}`); chart colors come from
`useArtifactTheme()` instead (see Charts below). Avoid arbitrary-value
classes (`p-[37px]`) — only standard utilities are guaranteed to be emitted.
- Don't set background colors (e.g. `bg-gray-50`). Let the theme background
show through, and use `@clarify/ui/card` for raised surfaces. Only set one
when the user asks for a specific color.
- NOT emit `<!doctype>`, `<html>`, `<head>`, `<body>`, a root/mount node, a
`<script>` tag, or any `createRoot(...)` call — the scaffold provides all of
that, and emitting them breaks the artifact.
## Clarify design-system components
These `@clarify/ui/*` modules are available in the frame and render with the
workspace theme (light/dark) automatically. Import only what you use. See
"Requirements and prerequisites" above for when to call
`get-artifact-component-doc` for a component's exact props.
- `@clarify/ui/button` — A themed button. Use for actions inside an artifact (filters, toggles, links). Standard button attributes (onClick, disabled, type) work as usual.
- `@clarify/ui/card` — A themed surface for grouping content into panels — the default frame for a section, chart, or stat. Compose the parts; CardTitle renders a heading, CardDescription muted subtext.
- `@clarify/ui/text` — The themed typography set — use these for ALL text instead of raw <p>/<span>/<h*> or Tailwind text utilities, so weight, size, and color stay on the design scale. DisplayText/Heading1–Heading4 are headings (largest → smallest); CaptionText is small muted; LinkText is an anchor.
- `@clarify/ui/avatar` — A circular avatar for a person or company. Compose AvatarImage with an AvatarFallback (initials shown while the image loads or is missing). Set size via className (e.g. size-8).
- `@clarify/ui/badge` — A small status/label pill — deal stage, a count, a state.
- `@clarify/ui/tabs` — A sectioned content switcher. Each TabsTrigger and its TabsContent share a `value`; drive it with defaultValue (uncontrolled) or value + onValueChange.
- `@clarify/ui/clarify-avatar` — Clarify's branded avatar: shows the image, else colored initials, shaped by the object type (round for people, squared for companies/deals).
- `@clarify/ui/name-chip` — A compact name + avatar chip for a person or company (a deal owner, a contact). Falls back to colored initials when there is no image.
- `@clarify/ui/dashboard-header` — The header at the top of a dashboard — title and an optional scope line (period, owner, filters applied). The "Last updated" freshness label and the refresh-all button are added automatically from the dashboard's own queries; do not pass them. Use exactly one per dashboard; for a section title inside the report use BlockHeader instead.
- `@clarify/ui/filter-bar` — A row that lays out filter and selector controls under the header, for reports meant to be re-sliced by the viewer. Skip it on a fixed view; it is only layout — put the controls inside it.
- `@clarify/ui/date-range-picker` — A dashboard's time control, placed in the FilterBar. Only add it when the artifact has ClickHouse (event) queries the range can scope — timeRange applies to ClickHouse only, so a Postgres-only (current-state) artifact must not include one; it would scope nothing. It opens on the last 30 days by default, so keep that default unless the report needs another window. To change it, defaultQuickSelect must be one of the built-in presets exactly — "Today", "Yesterday", "Last 7 days", "Last 30 days", "Last 90 days", "Month to date", "Year to date", or a quarter label like "Q3 2026" — an unrecognized label (e.g. "Last 6 months") is ignored and falls back to the 30-day default; for any other span pass defaultDateRange instead. Hold its onApply({ from, to }) range in state and pass it to each ClickHouse section as useQuery(sql, { timeRange: range }) — the backend scopes the query to it, so the picker label always matches what the queries run. Its Clear button sets the picker to "all time": onApply then fires with from/to undefined, so useQuery runs over all data — never pass a partial range. onApply also gives `previous` (the equal-length window just before the range, present only for a preset) for period-over-period comparison. Never fetch all rows and slice client-side, and never interpolate the dates into your SQL.
- `@clarify/ui/filter` — A dropdown filter for the FilterBar — lets the viewer narrow the dashboard by a value (owner, stage, region). Single-select by default (value is a string, onChange fires undefined when cleared); pass multiple for multi-select (value is a string[]). Hold the selection in state and pass it to each section's useQuery so the backend scopes the query; never fetch all rows and slice client-side.
- `@clarify/ui/block` — The card every metric, chart, or table sits in — never render one bare. Give it the section title with `title` (plus optional `info` for a definition and `description`), and pass the section's `query` (a useQuery result). The Block renders that query's loading skeleton, error, empty, and refresh states, then reveals your content once the rows arrive — so never hand-write isLoading/error/empty branches. Build the content from `query.rows`. Every query-driven block MUST also pass `underlyingQuery={{ sql, source?, title?, columns }}` — the detail records behind the aggregate — so the block offers a "See underlying data" drilldown (a block with a `query` but no `underlyingQuery` is incomplete). Author the detail SQL as the individual records (no GROUP BY), match its `source` to the block's query, and author `columns` with the right headers, formats (currency/percent/count/date), alignment, and any `render` — leave `columns` out only when you can't determine them, and the table falls back to auto-deriving number/date/text from the query's column types. Make every row's name link to its record by projecting `entity_id` and `entity_type` next to the record's name (aliased `name`) — on Postgres `deal._id AS entity_id, 'deal' AS entity_type, deal.name AS name` (use the record's own entity in place of `deal`); on ClickHouse `entity_id`, `entity_type_base AS entity_type`, and `argMax(JSONExtractString(properties,'name'), timestamp) AS name`. Keep a `name` column in `columns` — it renders the record as a clickable link — but leave `entity_id` and `entity_type` out of `columns`; they only carry the link target and never render.
- `@clarify/ui/block-header` — A block's header — a title, an optional description, an optional info tooltip for a definition, and an action row. Prefer passing `title`/`info`/`description` straight to `Block` (it renders this header for you); reach for BlockHeader directly only for a header outside a Block. Keep it to title, description, and one info affordance.
- `@clarify/ui/section-divider` — A light separator, optionally labeled, between clusters of blocks in a long artifact. Prefer whitespace first; reach for this only when a section break needs to be explicit.
- `@clarify/ui/use-query` — The only way to fetch live workspace data — a React hook with loading, refreshing, and error state, a refresh action, and auto-refetch when the SQL or timeRange changes. Give each data section its own useQuery so one slow or failed query never blanks the others. Pass a time window as useQuery(sql, { timeRange }) to scope the query server-side; never interpolate dates into the SQL. Give every query a title — the same string you pass to that Block — surfaced in server logs so a failing query is identifiable without decoding its SQL. Show a period-over-period comparison by default on time-scoped sections: add compareToPrevious — pass `range.previous ?? true` (`true` runs the same SQL for the equal-length window right before timeRange; the DateRangePicker onApply payload's `previous` is calendar-precise) — and read the prior period from `previousRows` (same shape as `rows`). It is a no-op without a timeRange.
- `@clarify/ui/artifact-theme` — The theme for charts. Call useArtifactTheme() and spread its `charts` values onto stock Recharts props so colors, axis, legend, and tooltip match the report and re-theme with light/dark — never hardcode chart colors. Pick the color group by the decision tree (stop at first match): positive/negative state → `charts.positive`/`charts.negative`/`charts.neutral`; ordinal dimension (stage, priority, tier, size/amount band) → `charts.sequential[i]` light-to-dark ordered by the band sequence (not the measure), even for a single series; more than one series in one space → `charts.categorical[i]` (six colors, wrap past six with `categorical[i % 6]`; `charts.other` for null/unknown only); otherwise a single `charts.primary`. Give pie/donut slices a thin `charts.pieStroke` border (half-opacity, blends light/dark). The legend key is a circle in our font (charts.legend). Order the data and the legend by rank (the measure, descending), else the data's natural order; alphabetical only as a last resort, never by default. Size the chart on the chart itself with `responsive width="100%" height={320}` — a fixed pixel height is required and a parent div's height does not flow into the chart. Give the Y axis headroom so the tallest value never touches the top — on bar/line/area set `domain={[0, (max) => Math.ceil(max * 1.1)]}` on the `<YAxis>`.
- `@clarify/ui/value-text` — Formats a single value for display — number, currency, percent, count, duration, or date. Reach for it for every numeric or date value so formatting stays consistent; it inherits the surrounding text size and color. Percent takes a fraction (0.12 → 12%); duration takes milliseconds; set `compact` to abbreviate large numbers ($2,480,000 → "$2.48M").
- `@clarify/ui/trend-indicator` — A change indicator: a direction arrow, the change value, and a comparison label. You rarely build this by hand — on a KPI, add a `comparison` to `MetricBlock` (`comparison={{ field, label }}`) and it renders this for you from the query. The comparison is free: `useQuery(sql, { compareToPrevious })` re-runs the SAME query for the prior period, so you never write a second query or diff values yourself — no reason to skip a trend on a time-scoped tile. Construct `TrendIndicator` directly only for a bespoke comparison whose two sides you already hold. `direction` sets the arrow; `sentiment` sets the color and defaults from direction — set `sentiment` to invert when up is bad (churn, cost).
- `@clarify/ui/metric-block` — A single KPI tile: a small title (with an optional definition tooltip), a large value, and — under it — an optional period-over-period trend and/or a muted caption. Self-contained (its own card) — drop it straight into a KPI strip, never wrap it in a Block. Group them in a 3-across grid and wrap to more rows as the count grows. Set `compact` to abbreviate large headline numbers ($2.48M, 47K). Give each tile its OWN `query` (a useQuery result) whose SQL returns exactly one row and the single value shown, and read it as `query.rows[0]?.<col>`. Do the aggregation in SQL (count/sum/avg/…, or a ratio via `countIf(…) / count()`) so a zero result shows as the value (0) rather than an empty state — never pass a multi-row query and derive the value in JS (`rows.reduce(…)`, summing or dividing across rows, or plucking one row out of many). Show a period-over-period trend by default whenever the tile is time-scoped: run the query with compareToPrevious and pass `comparison={{ field: "<col>", label: "vs previous period" }}` — `field` names the same column `value` reads, and the tile pulls the prior value and window straight from the query and computes the change itself (never compute a delta in JSX). Add `isInverted: true` for up-is-bad metrics (churn, cost). It shows a % for a nonzero prior, the raw figure for a prior of 0 (0 → 10 reads "+10"), or "Same as …" when unchanged; a missing/non-numeric prior or value shows no trend. `description` is a separate muted caption. Whenever the tile has a `query`, it MUST also pass `underlyingQuery={{ sql, source?, title?, columns }}` — the detail records behind the metric — so the tile offers a "See underlying data" drilldown (a query-backed tile with no `underlyingQuery` is incomplete); author it as the individual records (no GROUP BY) and give `underlyingQuery.columns` the right headers and formats, leaving them out only when the right columns are unknown. Make every row's name link to its record by projecting `entity_id` and `entity_type` next to the record's name (aliased `name`) — on Postgres `deal._id AS entity_id, 'deal' AS entity_type, deal.name AS name` (use the record's own entity in place of `deal`); on ClickHouse `entity_id`, `entity_type_base AS entity_type`, and `argMax(JSONExtractString(properties,'name'), timestamp) AS name`. Keep a `name` column in `columns` — it renders the record as a clickable link — but leave `entity_id` and `entity_type` out of `columns`; they only carry the link target and never render. A static tile with no `query` skips it.
- `@clarify/ui/data-table` — A sortable table of rows — reach for it whenever a block shows tabular data. Presentational: it renders already-loaded rows, so wrap it in a Block that owns the loading / empty / error chrome and pass `rows={query.rows}` and `truncated={query.truncated}`. For columns, either pass `columnTypes={query.columns}` to auto-derive them (numbers right-align, dates format, headers show even on 0 rows) and omit `columns`; or pass explicit `columns` — `{ key, header?, format?, align?, render? }` — when you need currency/percent/count formatting or a custom cell (auto-derive only knows number/date). `key` reads and sorts the cell; `header` defaults to `key`; `format` is one of number | currency | percent | count | duration | date; `align="right"` suits numbers; `render: (row) => …` draws a badge/chip/link (sorting still follows `key`). For sticky total / subtotal rows, pass `footerRows` — an array of `{ cells: { [columnKey]: cell } }`, one entry per row, top-to-bottom; each cell is EITHER `{ aggregate: "sum" | "avg" | "min" | "max" | "count" }` (computed over the shown rows and formatted with that column's format; `count` renders as an integer; on a date column only `count` applies, the others are dropped) OR `{ content: <node> }` for a label ("Total") or a precomputed value. An aggregate totals only the shown rows, so a `sum` under-counts when `truncated`. Every row always renders; by default ~10 show before the table scrolls — usually omit `visibleRows` and set it only to change how many are visible before scrolling, never to the total row count (that caps nothing and falls back to the default ~10). Do aggregation and column selection in SQL — never trim wide rows in the component.
- `@clarify/ui/chart-tooltip` — The Recharts `<Tooltip>` wearing Clarify's tooltip frame (dark rounded card). It IS the Recharts Tooltip, so render it directly inside a Recharts chart (`<ChartTooltip />`) and never pass it to another element's `content` prop — nesting one inside a Tooltip's `content` recurses without end. It only restyles the surrounding card and leaves Recharts to render the content.
## Fetching live data
Render live workspace data with the `useQuery` hook (`@clarify/ui/use-query`) —
the preferred way to fetch. It wraps the data path with loading, error, and
refresh state, so a section needs no fetch boilerplate. The iframe has no
network access, so never `fetch` or import from another origin.
- `const query = useQuery(sql)` → `{ rows, columns, rowCount, truncated, isLoading, isRefreshing, error, refresh, source }`.
Import it from `@clarify/ui/use-query`. Give EACH data section its own
`useQuery` — never gate the whole artifact behind one spinner — so one slow
or failed query never blanks the sections that are ready.
- **Hand the query to a `Block`; do NOT hand-write loading/error/empty.**
Wrap each section in `<Block title="…" query={query}>…</Block>` (or, for a
KPI, `<MetricBlock … query={query} value={query.rows[0]?.x} />`). The Block
owns that query's loading skeleton, error state, empty state, and refresh
control, and reveals your content once the rows arrive. Never write your own
`if (isLoading)` / `if (error)` / `if (!rows.length)` branch or a
`LoadingOrError`-style helper, and never pass `isLoading ? null : value` —
that duplicates the Block and drifts from the design system. Read `rows`
straight off the query (`query.rows`) for the content you render inside.
- **Do all aggregation, math, and filtering in SQL — never in JS.** The query
returns exactly the rows and values you render; JSX only reads `query.rows`
and displays them. Never sum, divide, average, count, slice, or filter across
rows in JSX (`rows.reduce(…)`, `rows.filter(…)`, `rows.slice(…)`) — push it
into the SQL (`count()`, `sum(m_amount)`, `avg(…)`, a ratio via
`countIf(…) / count()`, or a `WHERE` / `GROUP BY` / `ORDER BY` / `LIMIT`).
A `MetricBlock` is the scalar case: its query returns exactly one row and the
single value shown, read as `query.rows[0]?.<col>` (a scalar query also makes
a zero result render as `0` rather than an empty state).
- **Show a period-over-period trend by default.** "Is this up or down from
before?" is the first question a reader asks of any KPI or trend, so a
comparison is the highest-value thing most sections carry — add one to
**every time-scoped `MetricBlock` and single-measure line chart** unless a
prior period is genuinely not meaningful (see the skip list). This costs you
nothing extra: do NOT write a second query and do NOT diff values in JS. Add
`compareToPrevious={range.previous ?? true}` to the section's EXISTING
`useQuery` (the DateRangePicker's calendar-precise prior window, else the
equal-length shift) — the host re-runs that same SQL for the prior period and
returns it as `previousRows` (same shape as `rows`). Never hesitate for lack
of a comparison query; the flag *is* the comparison query.
- On a `MetricBlock`, pass `comparison={{ field: "<col>", label: "vs previous period" }}`
where `field` is the same column `value` reads; the tile pulls the prior
value and window from the query and computes the change itself — never
compute a delta in JSX. Add `isInverted: true` when up is bad (churn, cost).
- On a line chart, plot the prior window as a second series (see "Comparing
two periods on a chart" under Charts).
- Skip it only where there is no meaningful prior period: a non-time snapshot
(no `timeRange`), a Postgres current-state read, a pure breakdown or
distribution (share by stage / owner / category), or a cumulative
running-total measure. It is a no-op without a `timeRange`.
- The result carries `{ columns, rows, rowCount, truncated }`. `columns` is
`{ name, type }[]` (type is one of number | string | boolean | date |
datetime), present even for a 0-row result — hand it to a `DataTable`'s
`columnTypes` to auto-align/format and keep headers on an empty result. When
`truncated` is true the row cap was hit — tell the user the data was limited.
`error` is the typed failure (with a `code`); `refresh()` re-runs the query.
- Choose the store per query with `useQuery(sql, { source })`. The default
(omit it) is ClickHouse, the analytics event log — use it for history,
trends, point-in-time, and stage transitions. Pass `{ source: 'postgres' }`
only when the question is about current record state (the values as they are
now, a plain SELECT), which the event log can't answer as directly. The SQL
dialect differs per source (see the schema guidance below), so pick the
source first, then write for it.
- Pass a static SQL string literal so the query stays predictable and
validatable — never build SQL from runtime values, including dates. To scope
a query to a time window, pass the range to `useQuery` (below); the backend
applies it — never interpolate `from`/`to` into the SQL.
- To scope a section to a time window (e.g. a DateRangePicker's range), pass it
as the second argument: `useQuery(sql, { timeRange: { from, to } })`. The
backend applies it as a table-level row filter on `analytics.event`, so every
read of that table — nested subqueries, CTEs, and each `UNION` branch — is
filtered independently and the SQL stays static; never add your own
`timestamp` bounds for the picker's window. `useQuery` re-runs on its own when
the SQL or range changes, so the report re-slices on Apply with no extra
wiring. `timeRange` applies to the ClickHouse source only — a Postgres query
ignores it, so write explicit date `WHERE` filters there.
- **Scope every ClickHouse query to the artifact's window with `{ timeRange }`.**
The default source is the event log, so a metric read over all history is
almost never what the reader wants — pass `{ timeRange }` to every ClickHouse
`useQuery`, and give the artifact a date control (a
`@clarify/ui/date-range-picker`) to drive it. Add the picker **only when the
artifact has ClickHouse queries** — `timeRange` applies to ClickHouse only, so
a Postgres-only artifact (current record state) has nothing for it to scope
and must not include one; write explicit date `WHERE` filters in that SQL
instead. The picker defaults to the last 30 days, so a dashboard opens on a
bounded window and its queries stay fast; the viewer can Clear it to all time.
For a fixed snapshot with no picker, state the window in the UI.
- **Give every `useQuery` a `title` — the same string you pass to that
section's `Block`.** So a section wrapped in `<Block title="Deals by stage">`
fetches with `useQuery(sql, { title: 'Deals by stage' })`. The title is
forwarded to the server logs so a failing query is identifiable without
decoding its SQL; it has no effect on the result.
### Minimal example
```jsx
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { useQuery } from '@clarify/ui/use-query';
import { useArtifactTheme } from '@clarify/ui/artifact-theme';
import { Block } from '@clarify/ui/block';
export function ArtifactContents() {
const { charts } = useArtifactTheme();
const query = useQuery(
"SELECT entity_type, count() AS events FROM analytics.event WHERE workspace_slug = '<your workspace>' GROUP BY entity_type ORDER BY events DESC",
{ title: 'Events by type' },
);
// No isLoading/error/empty branches — the Block renders those from `query`
// and shows the chart once `query.rows` is ready.
return (
<div style={{ padding: 24 }}>
<Block title="Events by type" query={query}>
<BarChart responsive width="100%" height={320} data={query.rows}>
<CartesianGrid stroke={charts.grid.stroke} vertical={false} />
<XAxis dataKey="entity_type" tick={{ fill: charts.axis.tick, fontSize: charts.axis.fontSize }} />
<YAxis domain={[0, (max) => Math.ceil(max * 1.1)]} tick={{ fill: charts.axis.tick, fontSize: charts.axis.fontSize }} />
<Bar dataKey="events" fill={charts.primary} />
</BarChart>
</Block>
</div>
);
}
```
## Charts
Charts are Recharts (imported from `recharts`), styled by the artifact theme so
every report's charts read as one system. Four rules:
- **Size the chart with `responsive` + `width="100%"` + a fixed pixel `height`,
set on the chart itself** — e.g. `<BarChart responsive width="100%" height={320}>`.
`responsive` keeps the width fluid; the height must be an explicit number
because a chart has no intrinsic height. Put these on the chart, not a parent
`<div>` — Recharts draws to the chart's own box, so a parent's height does not
flow in and a bare `<BarChart responsive>` collapses to nothing. Do not use
`ResponsiveContainer`.
- **Give the Y axis headroom — the tallest value must never touch the top of
the frame.** Recharts otherwise caps the domain at the data's max, so the
biggest bar or point runs into the top edge. On bar/line/area, set
`domain={[0, (max) => Math.ceil(max * 1.1)]}` on the `<YAxis>`: keep the
baseline at 0 and round the top up past the tallest value. Pie/donut have no
axis, so this does not apply.
- **Take every color and label style from `useArtifactTheme()`** — never a
hardcoded hex, a raw `var(--color-*)`, or an opacity/tint variant.
`const { charts } = useArtifactTheme()` gives the color groups below plus
`charts.axis`, `charts.grid`, `charts.legend`, `charts.tooltip`, and
`charts.cursor`; spread each onto the matching Recharts prop (`fill`,
`stroke`, `tick`, …). Color must encode something — if it doesn't, don't
spend it; most charts are single-series and monochrome. Pick the group by
this decision tree, top to bottom, stop at the first match:
1. **Positive/negative or good/bad state** (won vs lost, on-target vs
at-risk, period delta) → the semantic set: `charts.positive`,
`charts.negative`, `charts.neutral` (excluded/no-value). If a chart uses
semantic colors, every series in it is semantic — never mix semantic and
categorical.
2. **Ordinal dimension** (stage, priority, tier, score/size band, an age or
amount bucket like "51-250" or "1K-5K") → `charts.sequential[i]`, light
to dark. This holds even for a single series — a lone bar or line over an
ordinal dimension is sequential, NOT `primary`. Order the axis by the
dimension's own sequence (1-10 → 100K+, P0 → S4), never by the measure,
so lightness climbs monotonically down the axis; spread across the ramp
rather than clustering (e.g. steps 0, 2, 3, 5). Steps 0–2 are fills only
— never lines or points. Never put a categorical color on an ordinal
dimension.
3. **More than one series in the same space** (stacked/grouped bar,
multi-line, stacked area, pie, donut, treemap) → `charts.categorical[i]`
in order. Six distinct colors; beyond six categories, wrap with
`charts.categorical[i % 6]` so colors repeat rather than run out.
`charts.other` is reserved for "Unknown"/"No value"/excluded categories
only — never a real category past the sixth — and always sits last in
stack and legend.
4. **Everything else** → a single `charts.primary`. When in doubt, this.
Assign deterministically so the same data yields the same colors on every
render and a category keeps its color across a dashboard. For categorical
and `primary`, sort rows by the charted measure descending, then assign in
token order. For a sequential/ordinal chart, order rows by the dimension's
natural sequence instead (rule 2), not the measure, so the ramp reads in
order. This row order sets the legend order too — the legend follows
row/series order. The precedence in general: order by rank (the charted
measure, descending); if there is no measure to rank by, keep the data's
natural order; fall back to alphabetical only as a last resort, when neither
exists — never sort categories alphabetically by default. When a chart
plots more than one series, give each a `name` and add
`<Legend iconType={charts.legend.iconType} wrapperStyle={{ color: charts.legend.text, fontSize: charts.legend.fontSize }} />`.
State the rule you applied when explaining a generated chart ("single
series, so primary"). See the `@clarify/ui/artifact-theme` entry above for
the full pattern.
- **Give pie/donut slices a blended border.** Spread
`stroke={charts.pieStroke} strokeWidth={1}` onto each `<Cell>` — a
half-opacity separator that reads in both light and dark. Never set a solid
white stroke; it looks wrong on a dark background.
- **Render the tooltip as `<ChartTooltip />` (from `@clarify/ui/chart-tooltip`)
directly inside the chart** — it IS the Recharts `<Tooltip>`, already framed.
Never pass it to another element's `content` (e.g. `content={<ChartTooltip/>}`):
`ChartTooltip` is the Tooltip, so nesting one inside a Tooltip's `content`
recurses without end and crashes the report.
- **Don't bucket a metric by time unless the user asked for that grouping.**
Reach for a time `GROUP BY` (`toDate(timestamp)`, `toStartOfWeek/Month(…)`)
ONLY when the request names the granularity — "by month", "per week",
"grouped by day", "weekly", "monthly". "Over time", "trend", or "as a line
chart" is NOT that request: a time series already IS a trend, so it can't be
the trigger. By default show a single aggregate over the report's window —
"deals created", not "deals created per month" — and let the report's date
filter (`timeRange`, above) set the period. Grouping by a non-time category
(stage, owner) is unaffected.
- **Compare two periods on a line chart by default.** A single-measure line
chart over the window should almost always show the prior period too, so
fetch with `compareToPrevious` and draw it as a second series: pair
`query.rows[i]` with `query.previousRows[i]` into one data array
(`{ bucket, current, previous }`) and plot two `<Line>`s. Color them
`charts.categorical[0]` / `charts.categorical[1]` (never hardcode); a dashed
previous line (`strokeDasharray`) reads as the baseline. This index pairing
is chart data shaping, not the banned in-JSX aggregation. Skip the overlay
only for a multi-series chart (a breakdown already uses the series slots) or
a non-time category chart.
## The `analytics.event` table (ClickHouse)
Read-only SQL runs against ClickHouse, not PostgreSQL — use ClickHouse syntax
(`JSONExtractString`, `toDate`, `argMax`, etc.). Every CRM record lives in
ONE append-only CDC event log, `analytics.event` — NOT a table of current
records; every create/update/delete is its own row. There is no `deal` /
`company` / `person` table (`FROM deal` fails with "Unknown table
expression identifier"). Always query `FROM analytics.event` and pick the
entity with `WHERE entity_type = '<type>'`.
Schema is inspired by PostHog: one wide event table with a JSON-stringified
`properties` payload plus a structured `actor` column. Hot keys are promoted
to materialized columns (below); everything else is read from `properties`
with `JSONExtract*`.
Every event carries a FULL snapshot of the record's fields in `properties` —
create, update, and delete alike. An update is not a delta: it repeats every
field the record has, not just the ones that changed. So the single latest
event per entity already holds every current field, and you never need to
stitch fields together across events. (The one exception: fields marked
sensitive are omitted from `properties` entirely.)
- `_id` (String) — Event id
- `workspace_slug` (String) — Workspace (tenant) slug
- `entity_type` (String) — Subject type: `deal`, `company`, `person`,
`meeting`, `message`, or `c_<slug>` for a custom object
- `entity_id` (String) — Subject record id
- `type` (String) — Event type: `clarify:create` |
`clarify:update` | `clarify:delete` (full enum below)
- `timestamp` (DateTime64(6,'UTC')) — Event time; use `toDate(timestamp)` for day grouping
- `properties` (String/JSON) — A full snapshot of the record's fields at event
time (every field, not just the changed ones); read with
`JSONExtractString/Float/Bool(properties, '<field>')`
- `diff` (String/JSON) — On update events, the field-level changes this event
applied: a JSON array of `{op, path, val, oldVal}` (`path` locates the field,
`oldVal` → `val` is the transition). Empty (`''`) on create and no-op events.
Use it to detect WHEN a field changed — see "Detecting when a field changed"
- `actor` (Tuple) — `actor._id`, `actor.anonymous_id`, `actor.entity`,
`actor.source_id` (all String, native paths — no `JSONExtract` needed); usable in `GROUP BY` / `ORDER BY`
- `m_stage` (String) — Materialized `JSONExtractString(properties,'stage')`; empty if absent
- `m_amount` (Float64) — Materialized `JSONExtractFloat(properties,'amount')`; `0` if absent
- `m_close_date` (Nullable(Date)) — Materialized `toDateOrNull(properties.close_date)`;
`NULL` if absent/unset. A native date — range-filter and group it directly
(`m_close_date >= '2026-01-01'`, `toStartOfMonth(m_close_date)`) with no `toDate` wrapper
### Reading fields is ClickHouse, not PostgreSQL
PostgreSQL JSONB operators `->` and `->>` do not exist in ClickHouse — `properties->>'description'` fails. Read fields with `JSONExtractString(properties, 'description')` (or `JSONExtractFloat` / `JSONExtractBool`). There is no `JSONExtractFloat64` — use
`JSONExtractFloat`. If a query fails because a function does not exist, use
the alternative ClickHouse suggests (e.g. `JSONExtractRaw`).
### Materialized columns
Prefer the materialized columns `m_stage` / `m_amount` / `m_close_date` over
`JSONExtract*(properties, 'stage'|'amount'|'close_date')` when the key matches —
they are typed, indexed, and skip a JSON parse per row. They are populated from
whatever `properties.stage` / `properties.amount` / `properties.close_date`
carries on each row, regardless of `entity_type`, so they work for a custom
object with those fields too. For `m_stage` / `m_amount`, empty / `0` means the
event didn't carry the key. `m_close_date` instead uses `NULL` for an absent or
unset close date (a date has no neutral sentinel), so test presence with
`m_close_date IS NOT NULL`, not `!= ''`.
`m_stage` (like every snapshot field) is the value the record held *when the
event fired*, not a transition. Counting `m_stage = 'Won'` answers "records that
were in Won while something happened to them" — an enrichment sweep, a note, or
an owner change re-emits the unchanged stage — so it tracks activity, not
outcomes. For "records that *entered* a stage", read `diff` instead (see
"Detecting when a field changed").
### Allowed `type` values
- `CdcEventType` — `clarify:create`, `clarify:update`, `clarify:merge`, `clarify:delete`, `clarify:add-to-list`, `clarify:remove-from-list`, `clarify:set-relationship`, `clarify:unset-relationship`, `clarify:grant-access`, `clarify:update-access`, `clarify:revoke-access`, `clarify:meeting`
### Scope every query (this is what keeps it fast)
The table is a `ReplacingMergeTree` ordered by
`(workspace_slug, entity_type, entity_id, timestamp, _id)`. Filter from the
left of that key so ClickHouse prunes the table:
- Always make `workspace_slug = '<your workspace>'` the first `WHERE`
condition — it is the leading sort-key column and prunes almost the whole
table. Use the Workspace value from your context; if it isn't there, call the
`get-current-user` tool to get it before querying.
- Always filter by `entity_type` next; add `entity_id` too for
single-record questions (that hits the sort-key prefix and reads a tiny slice).
- Time-series / activity / trend queries must also carry a `timestamp` range
(e.g. `timestamp >= now() - INTERVAL 90 DAY`) — both to scope the report and
to bound the scan. Current-state reconstruction is the exception (below): it
needs the full history per entity, so do NOT put a `timestamp` floor on it.
- Filtering only by columns outside the sort key (`type`, `m_stage`,
`m_amount`, `m_close_date`, or any `JSONExtract*` value) forces a full scan
— pair them with the sort-key columns above.
- Name the columns you need — avoid `SELECT *`. It returns the wide `data`
payload and computes the `properties` JSON alias for every row; list just
the columns the report uses (`entity_id`, `timestamp`, `m_stage`, …).
- Prefer `GROUP BY` on low-cardinality columns (`entity_type`, `m_stage`,
`toDate(timestamp)`, `entity_id`), not on a freeform
`JSONExtractString(properties, '<field>')` of a high-cardinality field (a
note, description, or free text) — that forces a JSON parse per row and
explodes the group count.
- When you only need to eyeball a few recent rows, bound the read with
`ORDER BY timestamp DESC LIMIT <n>` instead of scanning the whole slice.
- Date literals: prefer relative helpers (`now() - INTERVAL 1 MONTH`,
`toStartOfMonth(now())`, `today() - 7`) — you have no reliable wall clock,
and a hardcoded boundary silently drifts. A bare
`timestamp >= '2026-04-01T00:00:00Z'` rejects the ISO `T`/`Z`; use
space-separated `'2026-04-01 00:00:00'`,
`parseDateTime64BestEffort('2026-04-01T00:00:00Z')`, or
`CAST('2026-04-01T00:00:00Z' AS DateTime64(6, 'UTC'))`.
### Good vs bad query shapes
- Scope by the sort key, don't filter on a column alone:
- Bad: `SELECT count() FROM analytics.event WHERE m_stage = 'Won'` — no
`workspace_slug` / `entity_type`, so it scans every workspace.
- Good: `... WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal' AND m_stage = 'Won'`.
- Name columns, don't `SELECT *`:
- Bad: `SELECT * FROM analytics.event WHERE ...`.
- Good: `SELECT entity_id, timestamp, m_stage FROM analytics.event WHERE ...`.
- Bound a trend with a time range:
- Bad: `SELECT toDate(timestamp) AS day, count() FROM ... GROUP BY day` with
no `timestamp` floor — scans all history.
- Good: add `AND timestamp >= now() - INTERVAL 90 DAY`.
- Group low-cardinality, cap exploratory reads:
- Bad: `... GROUP BY JSONExtractString(properties, 'notes')`, or reading raw
rows with no `LIMIT`.
- Good: `... GROUP BY m_stage`, or `ORDER BY timestamp DESC LIMIT 20` to
peek at recent rows.
### Reconstruct current state from the log
To report on CURRENT state, rebuild it from the event history:
- Every event stores a full snapshot of the record, so reconstruct current field values with plain `argMax(<field>, timestamp)` grouped by `entity_id` — never a `JSONHas` / `<field> != ''` / `!= 0` presence guard, which resurrects a stale earlier value for a field that was later cleared or set back to 0. Read a JSON field with
`argMax(JSONExtractString(properties,'<field>'), timestamp)`, or a
materialized column directly with `argMax(m_stage, timestamp)`.
- Drop deleted records: `HAVING argMax(type, timestamp) != 'clarify:delete'`.
- Reconstruct in a CTE, then filter/aggregate over the CTE — filtering a mutable
field inside the per-entity scan changes which event counts as "latest" and
produces wrong totals.
```sql
WITH deal_current AS (
SELECT
entity_id,
argMax(m_stage, timestamp) AS stage,
argMax(m_amount, timestamp) AS amount
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
GROUP BY entity_id
HAVING argMax(type, timestamp) != 'clarify:delete'
)
SELECT stage, count() AS deals, sum(amount) AS pipeline
FROM deal_current
WHERE stage NOT IN ('Closed Won', 'Closed Lost')
GROUP BY stage
```
### Count stage transitions in a period
A snapshot count (`m_stage = 'Won'`) can't answer "how many deals were won this
quarter" — it counts deals *touched* while already won. Count the transition
instead: the events where `stage` moved into a closed value, plus deals created
directly in one. The `UNION` branch is required because `diff` is empty on
create — it catches deals imported straight into a closed stage.
```sql
-- Deals that ENTERED a closed stage inside the window.
WITH closed AS (
SELECT entity_id, timestamp, m_stage AS stage_at_event
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
AND type = 'clarify:update'
AND arrayExists(d -> JSONExtractString(d, 'path', 1) = 'stage'
AND JSONExtractString(d, 'val') IN ('Won', 'Lost'),
JSONExtractArrayRaw(diff))
UNION ALL
-- diff is empty on create: catches deals imported directly into a closed stage
SELECT entity_id, timestamp, m_stage
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
AND type = 'clarify:create' AND m_stage IN ('Won', 'Lost')
)
SELECT countIf(final_stage = 'Won') AS deals_won,
count() AS deals_closed,
countIf(final_stage = 'Won') / nullIf(count(), 0) AS win_rate
FROM (SELECT entity_id, argMax(stage_at_event, timestamp) AS final_stage
FROM closed GROUP BY entity_id)
```
The outer `argMax` collapses each deal to its last transition in the window, so a
deal that goes Won → Lost inside it counts once, as Lost. There is no delete
guard here on purpose — a deal won in the window was won even if the record was
later deleted; to drop since-deleted deals, reconstruct current state (above) and
keep only the `entity_id`s that are still live. Matching on `val` works because
`stage` is not sensitive; a sensitive field's `val` reads `'REDACTED'` (its
`path` is kept), so for those match on `path` presence alone.
For a "closed per month" trend, bucket by the event `timestamp` — that is when
the transition actually happened. One data limitation to surface, not hide: on a
batch-imported workspace every `clarify:create` lands on the
import date, so deals that closed before they were imported collapse onto that
date — the log has no real historical close date for them. `m_close_date` does
not fill that gap: it is the *expected* close date (often in the future), right
for a pipeline-by-expected-close forecast but wrong as a "when did we win"
bucket.
### Detecting when a field changed
A field being non-empty on an event does NOT mean it changed on that event —
every event repeats all fields, so an edit to one field re-emits the rest
unchanged. Use the `diff` column: on an update it lists exactly the fields that
changed, so "did this field change on this event" is a plain row filter. Unlike
comparing snapshots across events, this composes with a `timestamp` range and
stays fast. A field changed on an event when `diff` holds an entry whose first
`path` element is that field:
`arrayExists(d -> JSONExtractString(d, 'path', 1) = 'stage', JSONExtractArrayRaw(diff))`.
The matching entry also carries the transition — `oldVal` → `val` — so you can
report "moved from X to Y" without reading other events.
Caveats: `diff` is populated on `clarify:update` events only — it
is empty on `clarify:create`, so if you also need the value set
at creation (e.g. a deal's first stage), `UNION` in the create event. A change
to a sensitive field shows `val`/`oldVal` as `'REDACTED'` but keeps its `path`,
so the change is still detectable. Do NOT count a stage question off the
snapshot: `m_stage != ''` counts every edit to a record that has a stage, and
`m_stage = 'Won'` counts every edit to an already-won deal — neither is a stage
change. Match on `diff` instead (see "Count stage transitions in a period").
### Join across entities
Every entity lives in the same `analytics.event` table, so a cross-entity
report is CTEs joined on a foreign key stored in `properties`. Reconstruct
each entity's current state in its own CTE (the pattern above), then join on the
id — a deal's `company_id` links to the company's `entity_id`.
Scope the looked-up CTE to only the ids the join needs: add
`AND entity_id IN (SELECT <fk> FROM <driving_cte>)` to its `WHERE`.
`entity_id` is a sort-key column, so this prunes reconstruction to a tiny
slice instead of rebuilding every record of that entity type — the difference
between a many-second and a sub-second query.
```sql
WITH deal_current AS (
SELECT
entity_id,
argMax(JSONExtractString(properties,'company_id'), timestamp) AS company_id,
argMax(m_amount, timestamp) AS amount
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
GROUP BY entity_id
HAVING argMax(type, timestamp) != 'clarify:delete'
),
company_current AS (
SELECT
entity_id,
argMax(JSONExtractString(properties,'name'), timestamp) AS name
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'company'
AND entity_id IN (SELECT company_id FROM deal_current)
GROUP BY entity_id
HAVING argMax(type, timestamp) != 'clarify:delete'
)
SELECT c.name AS company, sum(d.amount) AS pipeline
FROM deal_current d
JOIN company_current c ON c.entity_id = d.company_id
GROUP BY c.name
ORDER BY pipeline DESC
```
### Common pitfalls
- `FROM deal` / `FROM company` — no per-entity tables exist. Query
`analytics.event` and filter `entity_type`.
- Reconstruction returns stale values — you added a `JSONHas` / presence
guard. Use plain `argMax` (see "Reconstruct current state from the log").
- Wrong totals when filtering a mutable field — you filtered it inside the
per-entity scan. Reconstruct in a CTE first, then filter over the CTE.
- Slow cross-entity join — the looked-up CTE rebuilt every record of its entity
type. Add `AND entity_id IN (SELECT <fk> FROM <driving_cte>)` so it only
reconstructs the records the join needs.
- `JSONExtractFloat64` does not exist — use `JSONExtractFloat`.
## Postgres source — current record state
Pass `useQuery(sql, { source: 'postgres' })` to read current record state
straight from Postgres — the values as they are now: deals in each stage right
now, counts and sums of present field values. Use it when the question is about
the present. For history, trends, point-in-time, or stage transitions, use the
ClickHouse source instead — Postgres holds no history.
- The schema is per-workspace and is NOT included here. Call
`get-schema` (format "read") for the entities you need before
you write SQL, then prove the query with `query-data`.
- Query each entity by its table name (`deal`, `person`, `company`, …). The
primary key is `_id`; audit columns are `_created_at` / `_updated_at`.
- Many columns are JSONB — use `->` / `->>`. A name stored as
`{first_name, last_name}` needs `->>`, not a bare ILIKE. Multi-select and
label arrays are `{items: string[]}`; test membership with `?|`
(`(person.labels -> 'items') ?| ARRAY['ICP']`), never `@>`.
- To-one links are foreign-key columns (`deal.company_id`, `deal.owner_id`);
many-to-many links go through join tables named by the two entities in
alphabetical order (`person_deal`, `person_meeting`).
- Postgres holds current values only — there is no history or point-in-time.
Do NOT pass `timeRange` to a Postgres query — it is rejected. Write explicit
date `WHERE` filters instead. For stage transitions, past values, or trends
over time, use the ClickHouse source.
## Keep queries simple
Prefer several small, focused queries (one per section) over one large
multi-join — it fits the per-section loading/error model above, so one slow or
failed query never blanks the sections that are ready. When a query uses a time
window, state the window in the UI so the reader knows the period and as-of
date, and whether it is a fixed snapshot (absolute dates) or a rolling window
(relative, recomputed on each open).
## Dashboards
When `type` is "Dashboard", the report is a multi-section overview. Its
sections can be ClickHouse (event / time-series), Postgres (current record
state), or a mix of both in the same dashboard.
Design it metric-first: for each section ask "what does it answer when the viewer
changes the window?" A section that only makes sense at one fixed window is the
wrong shape. When the ask is about change over time, prefer a dynamic,
date-filterable ClickHouse section (the event log, scoped by `timeRange`) over a
static Postgres current-state snapshot — a snapshot can't move with the window.
- Lead with a `@clarify/ui/dashboard-header`. Add a `@clarify/ui/filter-bar`
holding a `@clarify/ui/date-range-picker` **when any section has a ClickHouse
query the range can scope.** The picker drives `timeRange`, which applies to
ClickHouse only — so it scopes the dashboard's ClickHouse sections and its
Postgres sections ignore it. Omit the picker only when *every* section is
Postgres, since it would then scope nothing. When you include it, it defaults
to the last 30 days — keep that default unless the report needs another
window, then pass `defaultQuickSelect` (e.g. `"Last 7 days"`).
- When present, the picker is the dashboard's time control. Hold its `onApply`
payload (`{ from, to, previous }`) in React state and pass it to each
ClickHouse section as `useQuery(sql, { timeRange: range })`, so those sections
re-filter when the viewer changes the period. Never fetch all rows and
filter in the client, and never interpolate the dates into the SQL. (Postgres
queries ignore `timeRange` — see "Fetching live data".)
- **A dashboard is the strongest case for trends — its whole point is
change-over-time against a chosen window.** Pass
`compareToPrevious: range.previous ?? true` on essentially every KPI tile and
single-measure line chart, so each `MetricBlock` shows its `TrendIndicator`
and each trend line carries its prior-period baseline. A dashboard where the
KPIs show a bare number with no trend is the wrong default.
- Before adding a time-bucketed chart (a `GROUP BY` month/week), ask: if the
viewer picks "Last 7 days", does it still render a useful answer? A weekly bar
chart over a 7-day window is one bar. A time-scoped section usually answers "is
this better or worse than before?" — that's a KPI with comparison (a
`MetricBlock` with `compareToPrevious`), not a time-bucketed bar chart. Reach
for the bucketed chart only when the user asks for a trend by period (see the
time `GROUP BY` chart rule above).
A pipeline-health dashboard leads with a KPI strip that answers the window's
headline questions, each a comparison — deals won, win rate, pipeline created:
```jsx
const range = /* the DateRangePicker's applied { from, to, previous } */;
const won = useQuery(dealsWonSql, {
timeRange: range,
title: 'Deals won',
compareToPrevious: range.previous ?? true,
});
// winRate is the same shape with format="percent"; pipelineCreated with format="currency"
return (
<div className="grid grid-cols-3 gap-4">
<MetricBlock
title="Deals won"
query={won}
value={won.rows[0]?.deals_won}
format="number"
comparison={{ field: 'deals_won', label: 'vs previous period' }}
// detail SQL must project name (AS name), entity_id, and entity_type so rows link
underlyingQuery={{ sql: dealsWonDetailSql }}
/>
{/* win rate, pipeline created — same shape */}
</div>
);
```
## Important notes
Artifacts are read-only. `useQuery` runs SELECTs only; an artifact
cannot create, update, or delete records or take any action. Do not build
buttons, forms, or controls that mutate data, and do not tell the user an
artifact will perform a write or an action — offer the closest thing you can
actually do instead.
When you tell the user about the artifact in chat, describe WHAT it shows and
what it answers, not HOW you built it. Skip the CSS, query, and debugging
details unless the user asks for them.
This tool only persists the content or edits it is given; it does not generate
or edit content itself. Author the full source, or the exact edit strings,
yourself before calling this tool.