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.
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, deck, analysis, breakdown, summary, overview, 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. A deck, slides, a QBR, a pitch, or a readout is a slide deck built with @clarify/ui/deck (see "Building a deck").
- 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.
When you mention the result to the user, name it by what it is β a dashboard, report, deck, sales document, or plan. Do not call it an "artifact" unless the user used that word first.
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 every definition you have not saved, and ask the user whether to save it. If the user does not answer, offer again in each reply until the user answers. 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 use a component's props from memory or from its one-line summary. Always call get-artifact-component-doc first. No exceptions.
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.
Before you save an artifact version, check the source you are about to send. Fix every clear defect in that same version without asking. This holds for source the user pasted too, unless they said to keep it exactly as given. Leave working code alone. Add no feature the user did not ask for. In this check, a missing date picker, header, or period comparison is not a defect. The authoring rules still apply to source you write yourself. Tell the user in one plain sentence what you fixed, with no build details, e.g. "I also fixed two queries and removed unused code." The "Self-check" in the "artifact-docs" context lists the defects: dead code, broken math, manual work a built-in covers, missing contract, slow queries. Read it with read-context if you have not in this conversation.
Set type to "Dashboard" when the content you authored is an interactive dashboard, "Deck" when it is a slide deck, and "Document" when it is a page to read; leave it unset for other kinds of library item.
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).
- Pad the root so the report never sits flush to the frame: give the top-level
element ArtifactContents returns an outer padding class (p-6). Never return an unpadded root. Drop the padding only on a section you deliberately want edge-to-edge (a hero image, a full-width map). A slide deck does NOT get this padding: build it with @clarify/ui/deck (see "Building a deck"), which fills the frame with h-screen.
- 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 to
Heading4, Text, CaptionText), not raw <p>/<span>/<h*> and not type utilities (text-lg, font-semibold), so type stays on the design scale.
- Write a
Text nested inside another Text as
<Text as="span" size="inherit" weight="font-medium"> so it stays inline at the parent's size; a nested Text without as renders a div and breaks the sentence. Every URL, email address, and phone number is a LinkText with an href (mailto: for an email, tel: for a phone number), e.g. Email <LinkText size="inherit" href="mailto:phil@acme.org">phil@acme.org</LinkText> for access. A bare or bold email, URL, or phone number is a bug: the viewer cannot click it.
- Use only standard Tailwind utilities. The theme ships the standard layout,
spacing, and sizing utilities plus the semantic color utilities in Styling below. Avoid arbitrary-value classes (p-[37px]), because only standard utilities are guaranteed to be emitted. Chart colors come from useArtifactTheme() instead (see Charts below).
- Emoji are allowed, but prefer a Clarify icon (
@clarify/ui/icon) whenever
one fits. Never mix the two in one context: a single list, row of cards, or slide uses icons throughout or emoji throughout, never two emoji and one icon.
- 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.
Color comes only from these semantic utilities. Each one follows dark mode and the workspace palette, so one class renders right in both modes:
text-fg-1, text-fg-2, text-fg-3, text-fg-4, text-fg-onbrand, text-fg-on-inverse
bg-canvas, bg-surface, bg-subtle, bg-muted, bg-inverse
border-border, border-border-subtle, border-border-strong
text-danger, text-warning, text-success, text-info, bg-danger, bg-warning, bg-success, bg-info, border-danger, border-warning, border-success, border-info
bg-danger/10, bg-warning/10, bg-success/10, bg-info/10
bg-brand-subtle, text-brand-subtle-fg
text-accent-1, text-accent-2, text-accent-3, text-accent-4, text-accent-5, text-accent-6, bg-accent-1, bg-accent-2, bg-accent-3, bg-accent-4, bg-accent-5, bg-accent-6, border-accent-1, border-accent-2, border-accent-3, border-accent-4, border-accent-5, border-accent-6, from-accent-1, from-accent-2, from-accent-3, from-accent-4, from-accent-5, from-accent-6, via-accent-1, via-accent-2, via-accent-3, via-accent-4, via-accent-5, via-accent-6, to-accent-1, to-accent-2, to-accent-3, to-accent-4, to-accent-5, to-accent-6
bg-accent-1/10, bg-accent-2/10, bg-accent-3/10, bg-accent-4/10, bg-accent-5/10, bg-accent-6/10
- Set a background only through a surface token (
bg-canvas, bg-surface,
bg-subtle, bg-muted, bg-inverse) or a tint (bg-brand-subtle, bg-accent-1/10, bg-danger/10). Use @clarify/ui/card for a raised surface before you reach for bg-surface. On bg-inverse, color text with text-fg-on-inverse, which flips with the surface. text-fg-onbrand is a fixed white for an accent fill, so it vanishes on the dark-mode inverse.
- Never use a palette shade (
text-gray-600, bg-blue-50) or a hex value,
and never a CSS variable in an inline style for color. A shade ignores dark mode and the workspace palette. When the user asks for a specific color, use the nearest accent or status utility.
- Never restyle the internals of a design-system component, such as a Block's
border or a Card's padding. Wrap the component or pick a different one.
- Give each surface one accent moment at most: one
text-accent-1 figure,
one highlighted series, or one call to action.
- Keep a gradient rare. Build it from accent stops only
(bg-gradient-to-br from-accent-1 to-accent-3) and count it as the surface's one accent moment.
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). Standard button attributes (onClick, disabled, type) work as usual. To link somewhere, use a real <a href> (or RecordLink for a record), not a button.
@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 and Heading1 to Heading4 are headings (largest to smallest); CaptionText is small muted; LinkText is an anchor. Write a Text nested inside another Text as <Text as="span" size="inherit" weight="font-medium"> so it stays inline; a nested Text without as renders a div and breaks the sentence. A nested LinkText takes size="inherit". Every URL, email address, and phone number is a LinkText with an href (mailto: for an email, tel: for a phone number). A bare or bold email, URL, or phone number is a bug: the viewer cannot click it.
@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/icon β Clarify's icon set, imported by name as <Name>ClarifyIcon. Reach for one wherever a label earns a glyph: a section heading, a status row, a list bullet, a stat caption. Clarify's own nouns have their own icons, so prefer the specific name over a generic one: an autonomous agent is AgentClarifyIcon, not PeopleClarifyIcon. Every icon paints with currentColor, so color comes from a text-* utility on the icon or its parent (text-success, text-warning, text-danger, text-fg-3, text-accent-1) and size from the size prop, 16 by default. Emoji are still fine, but prefer an icon when one fits, and never mix icons and emoji in the same list, card row, or slide. The full set, each used as <Name>ClarifyIcon: Activity, AddAbove, AddBelow, Agent, Agent2, AIEmail, AINote, AIPlay, AITask, Alarm, AlignLeft, AlignRight, AngelList, ArrowDown, ArrowDownLeft, ArrowDownRight, ArrowLeft, ArrowLeftRight, ArrowRight, ArrowTrendDown, ArrowTrendUp, ArrowUp, ArrowUpDown, ArrowUpLeft, ArrowUpRight, Artifact, ArtifactDashboardSparkle, ArtifactDeckSparkle, ArtifactDocumentSparkle, Attach, BankNote, Bell, BellCheck, BellOff, BillingPlans, Binoculars, BoardView, Bolt, Book, Bookmark, Box, Braces, Briefcase, Calendar, CalendarCross, Campaign, Car, CaretDown, CaretLeft, CaretRight, CaretUp, Changelog, ChatDocked, ChatFloating, Check, Checkbox, CheckCircle, CheckCircleFilled, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, ChromeExtension, Circle, CircleDashed, CircleFilled, ClarifyAI, Cleanup, Clock, Collapse, Columns, Comment, Company, Component, Connected, Connectors, CreditCard, Credits, CursorClick, Customize, Database, DateNumbers, Deal, Delete, Deny, DesktopApp, DocumentUpload, Dot, Download, DragHandles, Duplicate, DynamicClock, Edit, EditOff, Email, EmailForward, EmailReply, EmailReplyAll, Enter, EnumFilled, EnumOutline, ErrorCircle, Expand, Eye, EyeClosed, Facebook, FileText, Filter, Flag, FlaskConical, Folder, Globe, GraduationCap, Granola, Graph, GroupView, Hand, Handshake, Hash, Heart, Home, Image, ImportToList, Inbox, InfoCircle, Instagram, Integrations, Intercom, InviteMembers, Json, Key, Keyboard, LastRun, Layers, Link, LinkedIn, ListView, Location, Lock, Logout, MapPin, Megaphone, Members, Mention, Menu, Minus, Mobile, Monitor, More, MultiSelect, NewChat, Note, NoteBook, Number, Objects, Package, Pause, People, Phone, Pin, Plus, Popout, PriorityHigh, PriorityLow, PriorityMedium, PriorityUrgent, Puzzle, Question, QuestionCircle, Receipt, Record, Refresh, RepFace, Rocket, RTEBold, RTECode, RTEH1, RTEH2, RTEH3, RTEItalic, RTElistCheck, RTElistOrdered, RTElistUnordered, RTEParagraph, RTEQuote, RTESmiley, RTEStrikethrough, RTEUnderline, Search, SendEmail, Settings, SettingsAlt, Shield, ShoppingCart, SidebarLeft, SidebarLeftOpen, SidebarRight, SidebarRightOpen, Skill, Slack, SocialAngellist, SocialFacebook, SocialInstagram, SocialLinkedIn, SocialX, Sort, SortAsc, SortDes, Sparkle, Spinner, Stage, StageOfDeal, StarFilled, StarOutline, StatusBlocked, StatusCanceled, StatusDone, StatusFailed, StatusInProgress, StatusPaused, StatusScheduled, StatusTodo, Stop, Store, Summary, Tag, TagRemove, Target, Text, ThumbsDown, ThumbsUp, Ticket, ToggleLeft, ToggleRight, Trophy, Truck, Typography, Undo, Unread, Upload, Users, Variable, VerificationFail, VerificationSuccess, Video, VideoBack10s, VideoCC, VideoForward10s, VideoFullScreen, VideoOff, VideoPause, VideoPIPExpand, VideoPIPMinimize, VideoPlay, VideoPlayFilled, VideoVolumeFull, VideoVolumeMute, View, Warning, Webhook, Workflow, Wrench, X, XSocial, Zap.
@clarify/ui/switch-icon β The default way to show a boolean in a report: a static on/off status icon (green when on, gray when off) for an active flag, an enabled setting, a yes/no cell. It shows state driven by checked; it is not an interactive control. Reach for @clarify/ui/switch only when the user must flip the value.
@clarify/ui/switch β An interactive on/off toggle (Radix). Use it only when the report needs a real control the user flips to change what they see; drive it with checked + onCheckedChange and your own state. To show a boolean as read-only status, use @clarify/ui/switch-icon instead.
@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. Dates after today are disabled by default; set canSelectFutureDates only for a forward-looking report (pipeline or forecast scoped by a future close date). 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, and argMax(JSONExtractString(properties,'name'), timestamp) AS name. The framework reads name, entity_id, and entity_type literally off each row, so any other alias (deal_id, record_id, a bare id) silently renders plain text β no link, no error, and nothing to self-heal from. Get the three names right the first time. 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. Color each <XAxis>/<YAxis> with stroke={charts.axis.line}, else Recharts draws its #666 default. 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 query rows by the dimension: rank by the measure descending, else the dimension's own order (stage, priority), alphabetical only as a last resort. Recharts <Legend> re-sorts keys alphabetically unless you pass itemSorter={null}, so ALWAYS set itemSorter={null} on <Legend> to keep it in the row order and matching the tooltip. 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), so 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, and argMax(JSONExtractString(properties,'name'), timestamp) AS name. The framework reads name, entity_id, and entity_type literally off each row, so any other alias (deal_id, record_id, a bare id) silently renders plain text: no link, no error, and nothing to self-heal from. Get the three names right the first time. Keep a name column in columns, which 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. MetricValue is the same value, trend, and caption with no card and no title, for a headline number on a slide inside SlideData: pass size="headline" there and let the slide carry the metric name.
@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. A name column links to its record automatically when the rows carry entity_id and entity_type β the SAME contract as underlyingQuery, so project those two next to the name (d._id AS entity_id, 'deal' AS entity_type on Postgres; entity_id and entity_type on ClickHouse) in any table query you want linked, not just drilldowns. All three names are read literally, so a different alias (deal_id) silently renders plain text. Leave entity_id/entity_type out of columns β auto-derive hides them once a row links, and listing them explicitly renders raw ids as their own columns. A column render overrides the link.
@clarify/ui/record-link β A record's name as a clickable chip that opens its record page. A DataTable already links its name column on its own, so reach for this only outside that column: a second record column in the same table (via that column's render), or a non-table layout like a card grid or a list. objectName is the entity (deal, company, person), recordId its id; take both from the row's entity_type / entity_id. It degrades instead of breaking: no recordId renders a non-clickable chip, no objectName renders plain text. A plain <a href> navigates too: an in-app path opens in the same tab, an external URL in a new tab. But prefer RecordLink for a record's name so you get the chip styling and this safe degradation, rather than hand-building a record URL.
@clarify/ui/chart-tooltip β The Recharts <Tooltip> wearing Clarify's tooltip frame (an inverted 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. With no content it renders Clarify's legend body: per series a color dot, the neutral name, and the right-aligned value. formatter and labelFormatter still work. Pass your own content to override the body.
@clarify/ui/deck β A slide deck, for anything the user will present: a deck, slides, a QBR, a pitch, a readout. Deck owns navigation, the thumbnail rail, the overview grid, present mode, and the slide transition; each Slide is a bare 960Γ540 design-px canvas (scaled to fit, clipped, never scrolling) with only children, notes, and className, so the author composes the whole slide with the text components, the semantic color utilities, and flex, grid, or absolute positioning. The slide sets its own type scale, so the text components render at slide size: CaptionText 14px, Text 16px, Heading2 24px, Heading1 32px, DisplayText 44px, and size="text-4xl" 56px, text-5xl 72px, text-6xl 86px for a cover title or a headline number. SlideData takes the slide's query (and underlyingQuery, same contract as Block) and renders the loading, error, empty, and refresh states around a chart, a DataTable, or a MetricValue with size="headline", with no card. Steps / Step reveal a slide one beat at a time. useSlidePageNumber() returns { current, total }. Slides must be direct children of Deck (fragments are fine; a component that renders slides is not). The authoring rules (every useQuery hoisted above the Deck, one idea per slide, semantic colors only, className="h-screen") are in the "Building a deck" section of the artifact tool.
@clarify/ui/document β A long-form page, for anything the user will read: a business case, a prospecting brief, a proposal, a memo, an account plan. Document owns the title block (title, description, meta badges), the page width and text scale, the paragraph measure and leading, and a table of contents that follows the reader and jumps to any section. Each Section has a title and children; a Section inside a Section is a subsection, and its heading level and table-of-contents depth come from the nesting. Callout (tone: note, info, warning, success, danger) is a tinted aside. Body copy is Text as="p", a list is a plain ul or ol with Text as="li" items, and a side-by-side row is a grid, as the usage shows. A document about workspace data is live like a dashboard: every figure renders from query.rows through a MetricBlock, MetricValue, chart, or DataTable in a Block inside a Section, and prose interpolates figures from the rows. The authoring rules (every useQuery hoisted above the Document, every heading a Section, className="h-screen") are in the "Building a document" section of the artifact tool.
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). Merging two query results by a stable key to add columns to one table (see "Keep queries simple") is a join by key, not this banned across-rows aggregation.
- 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 (see below for its dates).
- 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**, and never for a named period. A Postgres-only artifact gets no picker: it is current record state, with date WHERE filters relative to today (now(), CURRENT_DATE, date_trunc). A named period of any tense ("July", "Q4") is never Postgres-only, since current state filtered to it empties once it ends. Read it from the event log with typed dates: a fixed timeRange for activity, and for state an argMax with timestamp < '<day after end>', which tracks the period while it runs and freezes at its end. The picker defaults to the last 30 days, so queries stay fast; the viewer can Clear it to all time. With no picker, a rolling ask ("this month") builds its timeRange from new Date(). Ask when the user could mean either. 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.
- **A detail (drilldown) query must project
name, entity_id, and
entity_type.** A block's underlyingQuery.sql returns individual records, and the framework reads those three column names literally off each row to build the record link. On Postgres neither entity_id nor entity_type exists as a column, so alias them yourself β d._id AS entity_id, 'deal' AS entity_type, d.name AS name; on ClickHouse project entity_id and entity_type. Any other alias (d._id AS deal_id) silently renders plain text with no link and no error, so there is no failure to self-heal from β get the three names right while you write the SQL.
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 className="p-6">
<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" stroke={charts.axis.line} tick={{ fill: charts.axis.tick, fontSize: charts.axis.fontSize }} />
<YAxis domain={[0, (max) => Math.ceil(max * 1.1)]} stroke={charts.axis.line} tick={{ fill: charts.axis.tick, fontSize: charts.axis.fontSize }} />
<Bar dataKey="events" fill={charts.primary} />
</BarChart>
</Block>
</div>
);
}
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, β¦). Put stroke={charts.axis.line} on every <XAxis>/<YAxis> β without it Recharts paints its #666 default axis line. 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.
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
- Use
actor.entity and actor.source_id to tell an automated actor (an
agent or an API key) from a human user, and group or exclude by them when an automation may skew a metric
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
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).
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").
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:access-request-created, clarify:access-request-decided, clarify:meeting
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')).
- 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.
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.
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
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.
-- 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_ids 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.
When you do bucket a retrospective metric by a date field that can run into the future (m_close_date, any custom date field), cap the buckets to the current period β AND m_close_date <= today(), or HAVING bucket <= toStartOfMonth(today()). A future bucket holds only open pipeline, so a "what happened" metric (win rate, deals won) reads there as losses or zeros. Drop the cap only for an intentional forward-looking pipeline/forecast.
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").
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.
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
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.
- Summed or counted returned rows by hand β you totaled values in your
reasoning instead of in the query. Push it into the query (sum(...), count(), avg(...), countIf(...)) over the reconstructed CTE, and read the total from the result.
- Pasted a literal id list into
IN ('β¦','β¦') β you transcribed ids from a
prior result. Filter with a subquery instead: entity_id IN (SELECT <fk> FROM <cte>) (the join pattern above).
JSONExtractFloat64 does not exist β use JSONExtractFloat.
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.
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).
A table is not pinned to one query. To add a column to a table already in the artifact, edit that table β never add a second table beside it. Update the existing artifact with artifact_id and edits. First choice: widen that table's query β project the new column, or JOIN it in when it lives in the same source (see the cross-entity JOIN guidance above) β and add the matching DataTable column. Fallback, only when one query cannot return the column (a different source, e.g. a Postgres current-state table plus a ClickHouse stage-history metric, which cannot JOIN): run a second useQuery, build a lookup keyed by entity_id from its rows, map the first query's rows onto it to attach the column, and pass the combined array to the one DataTable.
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:
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 p-6">
<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, entity_id, entity_type β read literally
// off each row; any other alias silently drops the link (see Block docs)
underlyingQuery={{ sql: dealsWonDetailSql }}
/>
{/* win rate, pipeline created β same shape */}
</div>
);
A deck is the artifact for anything the user will present or walk someone through: they ask for a deck, slides, a QBR, a pitch, or a board or team readout. A dashboard is for watching numbers move against a window; a report is for reading. When the ask is "present", build it with @clarify/ui/deck (Deck, Slide, SlideData, Steps, Step) and fetch its props with get-artifact-component-doc like any other component.
- **Hoist every
useQuery to the top of ArtifactContents and pass the
results into the slides. Hard rule.** Only the on-screen slide is mounted, so a useQuery inside a slide refetches and shows a skeleton on every visit.
- The canvas. Each
Slide is an empty 960Γ540 design-px canvas, scaled
to fit the viewer. It clips and never scrolls, so anything past its edges is cut off. You compose the whole slide: the inset, the alignment, the type, the color, the hierarchy. Design each slide like a designer would, not like a document: one idea, one focal point, generous margins (p-12 to p-16), and few words.
- Type on a slide. The slide sets its own type scale, so the text
components render at slide size with no size classes: CaptionText for an eyebrow (14px, add uppercase tracking-wider), Text for body (16px), Heading2 for a subtitle (24px), Heading1 for a slide title (32px), DisplayText for a big title (44px). Three display sizes exist only on slides, through the size prop: text-4xl (56px) for a cover title, text-5xl (72px), and text-6xl (86px) for a headline number, where you wrap the MetricValue in <Text as="div" size="text-6xl">. Never write a raw text-size utility on a slide; pick the component or the size.
- Color on a slide. Use the semantic utilities and nothing else:
text-fg-1 to text-fg-4, bg-surface, bg-subtle, bg-muted, border-border, the status tones (text-danger, bg-success/10), and the six accents (text-accent-1, bg-accent-1/10, border-accent-1, gradients bg-gradient-to-br from-accent-1 to-accent-2). A dark cover or section slide uses bg-inverse with text-fg-on-inverse, which flips with the surface. Never pair it with text-fg-onbrand, a fixed white that vanishes on the dark-mode inverse. Never a palette shade (gray-700, blue-500) or a hex: those ignore dark mode and the workspace palette. Backgrounds are allowed on slides. One accent moment per slide is plenty.
- Layout on a slide. Flex, grid, and absolute positioning are all allowed
inside a slide (absolute inset-0, bottom-12 left-16), with the standard spacing and sizing scale. Arbitrary values (p-[37px]) are not emitted, so stay on the scale. Never add a scroll container.
- Shape. 6 to 12 slides. Open on a cover, divide chapters with a section
slide, end on a closing. One idea per slide. Vary the layout across slides: alternate full-bleed, split, and metric-focal compositions so neighboring slides do not share a template. A deck where every slide is the same layout reads as filler. When content does not fit, split the slide; never shrink type to cram it in.
- Data on a slide. Wrap a query-driven body in one
SlideData (it takes
the slide's query and underlyingQuery, same contract as Block) so the loading, error, empty, and refresh states render without a card. Inside it, build from query.rows: a MetricValue with size="headline" for a big number, a chart with height around 260 to 320, or a DataTable, which scrolls inside the slide, so return every row the story needs. A Block or MetricBlock is also fine when a card is what you want.
- A deck shows live data, with no controls. Every query runs again each
time the deck opens, so never call it a snapshot and never write "as of" with a date you typed. State the window on the cover, computed: a quarter or a named period as text, a relative window from new Date(). Pass a fixed timeRange to each ClickHouse useQuery and add no DateRangePicker or filter bar.
- Every number in the deck comes from the rows. A figure anywhere on a
slide or in notes is interpolated from query.rows, never typed from a query you ran while authoring. A typed number is stale the next time the deck opens.
useSlidePageNumber() returns { current, total }. Show the page
number on every slide after the cover, placed consistently (a corner is usual). Slides must be direct children of Deck (fragments are fine; a component that renders slides is not).
Steps / Step when the order of ideas is the point: a slide that lists
next steps, priorities, plays, or asks puts each one in a Step so it lands on its own click. Each Step must be a direct child of Steps.
notes carry what the presenter says on that slide, not a summary of it.
- Give the
Deck a title and className="h-screen" so it fills the
frame.
- Set
type to "Deck" so the library shows it as a deck.
- Self-review before you save: every
useQuery sits above the Deck;
every slide has one idea and nothing near its edges; every slide after the cover shows its page number and neighboring slides do not share a layout; type goes through the text components and color through the semantic utilities, with no palette shade or hex anywhere; the deck opens on a cover and ends on a closing; the window is stated; no typed number or date anywhere; "snapshot" appears nowhere.
A document is the artifact for anything the user will read rather than watch or present: a business case, a prospecting brief, a proposal, a memo, an account plan, a one-pager. A dashboard is for watching numbers move; a deck is for presenting. When the ask is "write", build it with @clarify/ui/document (Document, Section, Callout) and fetch its props with get-artifact-component-doc like any other component.
- **A document about the workspace's data uses live components, the way a
dashboard does. Hard rule.** Hoist every useQuery to the top of ArtifactContents and pass the results into the sections. A section that makes a claim about numbers renders them from query.rows through a MetricBlock, a MetricValue, a chart, or a DataTable inside a Block, and a Callout or paragraph interpolates its figures from the rows, never types them. Static prose is for framing, recommendations, and narrative; anything the reader would want current comes from a query. A document about the user's pipeline, deals, accounts, or activity with no useQuery is the wrong build.
- Shape. One
Document with a title, a one- or two-sentence
description, and meta badges (the date computed from new Date(), the audience, a status). Then three to eight top-level Sections, each with a plain title; nest a Section inside another for a subsection. The table of contents builds itself from the sections, so every heading is a Section, never a bare heading. Two levels of nesting is the usual limit; three is the most the sidebar lists.
- Prose is
Text from @clarify/ui/text with as="p". The document
sets its measure and leading. Three to five sentences each, one idea per paragraph. Emphasis inside a paragraph is a nested Text with as="span", size="inherit", and weight="font-medium", so it stays inline. An email address, URL, or phone number in prose is a LinkText from @clarify/ui/text with href (mailto: for email, tel: for a phone number), never plain or bold text. A list is a ul or ol with list-disc or list-decimal, pl-5, and flex flex-col gap-2, each item a Text as="li". A nested list sits inside an item with mt-2 and switches to list-[circle], or list-[lower-alpha] for an ol. Never write a raw <p> or heading tag.
- Asides.
Callout with a tone: info for a recommendation,
warning for a risk, success for a decision or milestone, danger for a blocker, note (the default) for context. At most one or two per section. A customer's or stakeholder's words go in a blockquote with border-l-2 border-border-strong pl-7 py-2, holding a Text at size="text-lg" and a Text as="cite" at size="text-sm" with variant="secondary" and not-italic.
- Laying out data. Each query-driven figure sits in a
Block or
MetricBlock inside the Section that makes the claim. Two to four blocks side by side use grid grid-cols-2 gap-6 (or grid-cols-3 gap-5, grid-cols-4 gap-4) with each child in a min-w-0 div; prose stays single column. A document is read at about 880px, so a chart's height stays at or under 320. A table of non-data content (a plan, an option comparison) uses DataTable from @clarify/ui/data-table with a plain rows array.
- Live dates. Every query runs again each time the document opens, so
never write "as of" with a date you typed and never call the data a snapshot. A figure typed from a query you ran while authoring is stale the next time the document opens. State the window in the description or a meta badge, computed.
- Give the
Document className="h-screen" so it fills the frame and
the sidebar can scroll with it.
- Set
type to "Document" so the library shows it as a document.
- Self-review before you save: every
useQuery sits above the
Document; each claim about numbers renders from query.rows; every heading is a Section; every paragraph is a Text; the window is stated; no typed number or date anywhere; "snapshot" appears nowhere.
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.