Clarify MCP logo

Integrate Clarify MCP with your AI CRM

Clarify MCP lets agents query and update CRM records, create tasks and lists, analyze pipelines, and work with companies, people, deals, meetings, and campaigns.

Explore Triggers and Actions

Add-comment

Add a comment to a supported entity. ## Important notes After adding a comment, tell the user what was commented on in a friendly, conversational way.

ActionTry it

Create-campaign

Create a new email campaign (sequence) in DRAFT mode. To edit an existing campaign, use the update-campaign tool instead. 📖 **For comprehensive campaign rules and examples**: Use `read-context` with context: "campaign-docs" ## When to use this tool - When the user asks to create a new email campaign or sequence - When duplicating a campaign (first fetch it with `get-campaigns`, then call this tool with the email steps) ## Structuring the campaign Structure email steps with subjects, bodies (HTML), and timing. Campaigns start in DRAFT mode. When drafting campaign emails: - Keep subject lines under 50 characters, conversational, not overly formal - Use a warm, conversational tone while remaining professional - Be concise and include a clear call to action if appropriate - Start with a friendly greeting and end with a warm closing ## Essential rules 1. **Email/Delay Pattern**: Emails and delays MUST alternate. Always specify `delay_after_days` on each step 2. **First email**: `delay_after_days: 0`. Subsequent emails: minimum 1 day 3. **Campaigns are Workflows**: Use this tool for campaigns, not generic workflow tools 4. Campaign emails are templates sent to multiple people 5. Use variable placeholders: {{path||fallback}} with fallback, or {{path}} without fallback 6. Only use the variables listed below (do not invent variables) ## Threading: send_as_reply Set `send_as_reply: true` on a step to thread it as a reply under the previous step (uses the prior message id and "Re: <previous subject>"). Use it when a follow-up bumps the prior email and the recipient should see it inline: - **Use** `send_as_reply: true` for content like "circling back", "just bumping this", "wanted to follow up", or any step that explicitly references the prior email's call to action without a new pitch. - **Leave it off (default)** when the step introduces a new angle, case study, or call to action; recipients triage by subject, and a fresh subject signals fresh content. Rules: - Step 1 always starts a new thread (`send_as_reply` must be false / omitted). - When `send_as_reply: true`, the step's `subject` is ignored at send time (the previous step's subject is reused with "Re: " prepended), but you must still provide a sensible `subject` for storage. Fallback rules (fallbacks make emails feel natural when data is missing): - ALWAYS include fallbacks for human-identifiable information: → Person names (first_name, last_name, full_name) → use "there", "Friend", etc. → Company/organization names → use "your company", "your organization", "your team" → Job titles and roles → use "your role", etc. → Location/city names → use "your area", "your region", etc. - Skip fallbacks ONLY for technical/structured data: → URLs, email addresses, phone numbers → Dates, timestamps, IDs → Numerical values and metrics ## Examples <example> Create a campaign: { "campaign_name": "New Leads Nurture", "email_steps": [ { "subject": "Welcome, {{person.name.first_name||there}}", "body": "<p>Hi {{person.name.first_name||there}},</p><p>Thanks for your interest!</p>", "delay_after_days": 0 }, { "subject": "Resources for {{person.company_id.name||your team}}", "body": "<p>Hey {{person.name.first_name||there}},</p><p>Here are some helpful resources...</p>", "delay_after_days": 3 } ] } </example> ## Important notes - List is optional for drafts (can be selected later in UI before activation) - Email account connection is optional for drafts (required before activating) - Campaigns are created in DRAFT mode (enabled: false) - delay_after_days: 0 = immediate, 3 = day 3, 7 = day 7 - Minimum 1 day enforced between consecutive emails automatically - Body should be formatted as HTML. Put each paragraph in its own <p> tag; paragraphs render with a blank line between them. Use <br/> only for a hard line break within a paragraph (e.g. between signature lines) - When describing timing to users, say "immediately" for delay_after_days = 0, "on day 3" for delay_after_days = 3

ActionTry it

Create-email-draft

Create an email draft in the user's connected Gmail or Outlook account for them to review, edit, and send themselves. Nothing is sent; use send-email when the user wants the message to go out now. The tool result repeats the draft's recipients, subject, and body, along with the Nylas draft ID needed to revise it. The body is plain text that will be converted to rich text blocks. Use these conventions: - Lines starting with "- " become bullet list items - Lines starting with "1. ", "2. ", etc. become numbered list items - Blank lines become paragraph breaks (consecutive blank lines are collapsed) - Lines starting with "# " or "## " become bold section labels - **bold** spans become bold text - Markdown links [label](https://example.com) and bare URLs become clickable links - Never put a placeholder or fabricated link in the body (e.g. [Recording] *(link)*, [link], or a guessed URL). Include a link only if you have the real URL from a tool result; if a referenced resource has no link available, leave it out and say in your reply that it needs to be added - Markdown horizontal rules like "---" are treated as section dividers and are not shown - Everything else becomes a regular paragraph Follow the system prompt and any activated email-writing skill for the user's preferred voice, tone, structure, and formatting judgment. If a skill gives more specific email-writing guidance that conflicts with these generic style defaults, prefer the skill. If no more specific guidance applies, write like a normal email, not a dashboard or report. Keep bodies compact and easy to scan: - Prefer short paragraphs and a small number of bullets - Use section labels only when they materially improve scanability - Use at most one blank line between sections or paragraphs - Keep section labels attached to their content; do not add a blank line immediately after a section label - Keep lists attached to the sentence or item they explain; do not add a blank line immediately before a bullet or numbered list - Avoid em dashes and dash-heavy constructions; use commas, colons, parentheses, or separate sentences instead - Do not use decorative separators, tables, code fences, blockquotes, or report-style spacing ## Threading When the user asks you to reply to an existing inbound email or thread, populate replyToMessageId so the draft threads correctly in Gmail/Outlook. Look up the value from the message_id field on a Message record (use get-records or query-data on the Message entity to find the inbound message you are replying to). Omit replyToMessageId when composing a brand new email that is not part of an existing inbound thread. replyToMessageId and replacesDraftId are different ids and must not be swapped. replyToMessageId is the short Nylas message_id of an inbound message (e.g. 19df41adc5abe23b). replacesDraftId is a Nylas draft id from a prior create-email-draft result (the "Nylas draft ID:" line). Never pass a Nylas draft id as replyToMessageId, and never pass a message_id as replacesDraftId. ## Iterating on a draft When the user is revising a draft you already created earlier in this conversation (e.g. "make it shorter", "rewrite it", "tweak the opener", "also add ..."), set replacesDraftId to the Nylas draft ID from that prior create-email-draft tool result. This updates the existing Gmail draft in place instead of leaving a duplicate behind. The Nylas draft ID appears on the "Nylas draft ID:" line of the prior tool result. Prefer this over creating a second draft whenever the user is iterating on the same email. Omit replacesDraftId when you are drafting a brand new email; for example for a different recipient, a different thread, or any message that is not a revision of the most recent draft to the same audience. ## Recipients Recipient addresses (to/cc/bcc) must come from a CRM person record. Look them up with get-records or by querying the person entity before drafting; never guess an address from a name and company domain. Any recipient that matches no person record is rejected unless you pass it in confirmedNewRecipients (only for addresses the user supplied for a brand-new contact).

ActionTry it

Create-or-update-agent

Create or update an agent, an autonomous AI assistant. ## When to use this tool - When the user asks you to create or update an agent ## Before you build: is an agent the right tool? Run these checks before planning. If one fails, say so and propose the better path rather than building a degraded agent. **1. Can the platform do it?** The triggers below are the only events an agent can respond to. Notably, there is no delay trigger: "do X three days after event Y" must be re-expressed as a recurring schedule that queries for records that have reached that state. Also confirm the request fits the cadence, delivery-channel, and data-access limits documented in the planning and trigger sections below. **2. Is an agent the right fit?** An agent is right whenever a task needs to run automatically on a trigger and involves any reasoning, including per-record classification like "categorize as ICP" or "classify meeting type." One ask isn't an agent: "keep a collection of records matching criteria" is a dynamic list. Scope every agent tightly (see "Narrow the scope with filters" below): ask how the relevant records enter Clarify, and if they share a label or marker, suggest a dynamic list and an `On add to list` trigger so the agent runs only for that subset rather than every record. For enrichment, use `find-leads`/`import-leads` for net-new prospects and a filtered agent for existing records. ## Before creating a new agent Do not immediately create an agent when the user asks. First, have a brief conversation to understand their goal and tailor the agent to their needs. Ask the user clarifying questions. 1. **Check for existing agents**: Use `get-agents` to see what agents already exist in the workspace. This is a silent background check; if no agents exist, do not acknowledge it; just continue. If an agent already covers the requested use case, point the user to it instead of creating a duplicate. If one is close, mention it and ask whether they'd like to update the existing agent or create a separate one. You can only update or delete an agent you created. `get-agents` shows each agent's `Owner` (name and email); you own an agent when its Owner is you. Never pass the `agentId` of an agent owned by someone else; updating it will be rejected. When the only close match belongs to another person, create a new agent for this user instead of trying to update theirs. 2. **Understand the use case**: Ask what they want the agent to do, what triggers it, and what the expected outcome is. Dig into vague requirements, e.g., if the user says "categorize people as ICP", ask what their ICP definition is (unless the org description already defines it). If they say "update fields after meetings", ask which fields. 3. **Narrow the scope, or batch on a schedule**: When a trigger would fire on every record of a type (empty filters — e.g., all person creates, all deal updates) or on every email (an unfiltered `message` trigger), call this out to the user as high-volume before saving, rather than quietly narrowing it yourself. Explain the cost: every matching event starts a billed run, even when the agent reads the record and correctly decides to do nothing, so breadth drives cost regardless of the agent's judgment. Then propose a concrete way to tighten scope and let the user decide. Two levers: - **Filters** (specific lists, field conditions, record types) keep the per-event trigger but skip runs that don't match. - **A schedule trigger** replaces many per-event runs with one recurring run that queries for the records needing work and processes them in a batch — far fewer runs. Prefer this only when the work tolerates a delay (up to the schedule interval) and the target records can be found by a query (they've reached some state), not when the agent must react immediately (e.g., alert the moment a deal is won) or needs the change event's before/after values. 4. **Warn about data visibility**: Agents run with the creating user's permissions. They can only see emails the user sent, received, or was CC'd on, and meetings the user is a participant of, unless teammates have shared access to meetings and emails via Settings. Tell the user this when creating agents that process emails or meetings, and suggest they ask teammates to share access via Settings if the agent needs broader visibility. 5. **Suggest complementary agents**: If the agent they want would benefit from a prerequisite agent (e.g., they want post-meeting CRM updates but don't have an agent that classifies meeting types first), mention that and offer to create both. 6. **Consider delivery channel**: If the agent produces output that the user needs to see (e.g., pipeline reviews, daily summaries, alerts when deals change stage), ask how they want it delivered. The only supported delivery channels are: - **Slack** (via Slack MCP connector): post to a channel or DM. Most common for scheduled reports and alerts. - **Email**: send a summary email to the user or a distribution list. Do not suggest delivery channels that don't exist (e.g., posting in chat, push notifications, in-app alerts). Only ask about delivery when the agent produces user-facing output, not for agents that silently update CRM fields. 7. **Select tools and connectors**: Based on the agent's purpose, set `tools` and `mcp_servers` to explicitly list every tool the agent needs. Only listed tools are available; omitted tools cannot be used. For each tool, set its permission: - `always_allow`: the agent can use this tool without asking - `needs_approval`: the agent must ask the user before using this tool Set read-only tools (e.g., `query-data`, `get-records`) to `always_allow` by default. **Current state vs. history**: `query-data` reads the live CRM (PostgreSQL): current field values, open deals, this week's pipeline. It cannot see how a record changed over time. For any question about change history or point-in-time state (stage transitions, deals that moved to a later stage this week, time-in-stage, records created or updated per day, who changed a field and when) grant `query-analytics`, which queries the analytics event log of every CRM change (including historical stage transitions). Never have an agent approximate history from `_updated_at` or claim stage transitions aren't stored; they are recorded and queryable via `query-analytics`. For write tools (e.g., `create-or-update-records`, `send-email`), always ask the user whether they want the agent to use them freely or require approval each time. Present it concisely: "Should the agent update records automatically, or ask you first?" If the agent needs an MCP connector (e.g., Slack for posting messages, Linear for issue tracking), add it to `mcp_servers` and list the specific tools needed with their permissions. Server IDs come from existing agent configurations (via `get-agents`); calling with an unknown server ID returns an error listing the workspace's available MCP servers and their IDs. **Connect connectors before saving**: an agent can only use an MCP connector the user has actually connected. Before calling this tool with any `mcp_servers`, first check their status with `get-mcp-servers`. For any required connector that is not connected, call `connect-mcp-server` and wait for the user to finish connecting it (re-check with `get-mcp-servers`) before saving the agent. Never save the agent while a required connector is still unconnected; saving will fail validation, and the user should connect everything first. If the user must connect multiple connectors, get them all connected before the single create/update call. **Replacement semantics**: both `tools` and `mcp_servers` are full replacements, not merges. When updating an agent, always include the complete set of tools and servers the agent needs, not just the ones being added. Omitting a previously configured tool removes it. To keep existing tools unchanged, omit the `tools` / `mcp_servers` field entirely. When adding a tool to an existing agent, first use `get-agents` to read the current configuration, then pass the full set including the new addition. Whenever you change an agent's tools, review its instructions in the same update and revise any step that no longer matches the available tools, so instructions and tools never drift apart. **Tool dependencies**: some tools require other tools to function. If you include any tool on the left, you must also include the tool(s) on the right: - `query-data` → `get-schema` (the agent needs to load CRM schema before writing SQL) - `query-analytics` → `get-schema` (the agent needs CRM field names to read them out of the event log) - `create-or-update-records` → `get-schema` (the agent needs to discover writable field names) - `create-or-update-list` → `get-schema` (lists are defined by SQL queries that need schema knowledge) - `import-leads` → `find-leads` (importing requires search context from a find-leads search) **Write instructions in plain language**: describe what the agent should do ("look up the person record", "update the company", "post to the channel"), never the literal tool name or MCP tool ID. Tool names can change, and naming them in the instructions wastes a tool-search step and can confuse the agent. The `tools` and `mcp_servers` fields decide what the agent can use; the instructions only describe the goal. 8. **Choose a model**: Set `model` from the task's complexity yourself. Use `fast` for simple, narrow, deterministic work (classification, field extraction, tagging/routing, short summaries) and high-volume triggers — it is faster and meaningfully cheaper. Use `smart` (the default) for multi-step reasoning, judgment, or quality-sensitive writing like emails and summaries. Only ask the user to pick when the task's complexity is genuinely ambiguous. Skip this planning step when the user provides a fully detailed specification upfront (trigger, instructions, and expected behavior). When updating an existing agent, skip planning unless the request is vague and you need more context to make the right change. ## Backfill existing records Users often want an agent to process existing records, not just future ones. When they mention "backfill", "run on existing data", or "process all current records": Do not suggest creating a separate backfill agent. Instead, add backfill capability to the same agent by including an hourly schedule trigger alongside the event-based triggers. The agent's instructions should include a backfill section that: 1. Queries for unprocessed records in batches each run. 2. Tracks progress by filtering for records that still need processing. 3. When all records are processed, the agent edits its own instructions and triggers to remove the backfill logic and the schedule trigger, keeping only the event-based triggers for ongoing processing. 4. Notifies the user via Slack that backfill is complete. ## Popular agent ideas When the user is exploring what agents can do or has a vague request, suggest ideas from this list if they are relevant to the user's business context. These are just starting points; only suggest ones that make sense for the user's situation: - **Post-meeting CRM update**: After a meeting transcript is ready, extract key takeaways, update deal stage, next steps, and relevant fields on the associated records. - **Meeting type classification**: When a meeting is created, classify it (e.g., discovery, demo, negotiation, check-in) based on attendees, title, and context, then set the meeting type field. - **Post-email CRM update**: After an important email is received or sent, update relevant deal or contact fields (e.g., sentiment, next action). - **Weekly pipeline review** (Slack): Summarize pipeline changes, stale deals, and upcoming closes, then post to a Slack channel. - **Deal stage change alert** (Slack): When a deal moves to a key stage (e.g., closed-won, closed-lost), post a summary to Slack. ## After creating or updating an agent Once the agent is fully configured and ready to use, suggest a test run to the user. Do this after both creating and updating an agent. If you are still configuring it across multiple steps, wait until it is complete before suggesting a run. When you tell the user the agent is ready, also tell them which model tier it runs on (`fast` or `smart`) and why you chose it, so the model choice is never silent. On an update, mention the tier only when you changed it. If the agent's trigger is broad or high-volume (fires on every record of a type, or on every email), also tell the user you flagged it and what you suggested to narrow the scope — or, if they chose to keep it broad, note that — so the cost trade-off is never silent. When you have just created a new agent, also give the user a brief heads-up that running an agent consumes variable credits with a 1-credit minimum, and link the pricing docs: https://docs.clarify.ai/en/articles/15193255-agents#h_bbf4c7aa36. For an existing agent that has run at least once, use `get-agents` to inspect its stats and use Avg Credits/Run as its typical per-run cost. If an agent has no run history, say there is no cost history yet and do not estimate or provide a cost range. ## When the user reports an agent issue If the user says an agent isn't working or asks why it did something unexpected, do not immediately change the agent's instructions. First, consider: 1. Check whether the agent's current instructions already address the issue. If they don't, you can update them directly. 2. If the instructions look correct, use `get-agent-runs` to fetch the most recent run transcript and diagnose what actually happened. 3. After diagnosing, explain your findings and suggest targeted fixes. Then offer to rerun. When the reported problem is a wrong or broken record URL, do not repair or guess a corrected URL from the existing instructions; that text is not a source of truth for URLs. Take the correct URL from tool output, such as the `Link:` in the run transcript (via `get-agent-runs`), and copy it exactly, or rewrite the instructions to have the agent use the `Link:` its own tools return.

ActionTry it

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.

ActionTry it

Create-or-update-calendar-event

Create a new calendar event or update an existing one. ## When to use this tool - To create a new event: omit `event_id` and provide `title` and `when`. - To update an existing event: provide `event_id` along with the fields to change. ## Important notes - Only the fields you provide are updated; omitted fields remain unchanged. - For recurring events, updates apply to the whole series. - `notify_participants` defaults to `true` on create (invite goes out) and `false` on update (so minor edits don't spam attendees). Set it explicitly when rescheduling or making changes attendees should know about. - When updating `participants`, the default is `add`: the listed emails are merged into the existing attendee list. To remove attendees, set `participants_mode` to `replace`: anyone not listed will be removed. - Get the user's timezone from get-current-user and pass it explicitly via `when.timezone` for timed events. Don't guess. - The write happens as soon as this tool is called, with no confirmation step. Confirm with the user first.

ActionTry it

Create-or-update-custom-object

Create a new custom object type or update an existing one in the workspace. ## When to use this tool - When the user asks to create a new custom object type - When the user asks to update the name, plural label, description, or avatar (icon and background color) of an existing custom object ## How it works - Without entity: creates a new custom object (name, plural, and description are required) - With entity: updates the specified custom object's metadata, including its avatar icon and background color ## Recommended workflow for create 1. Discuss with the user what the custom object represents 2. Ask for the object name and suggest 2-3 plausible names based on the conversation (e.g., "Contract", "Ticket", "Project"). Confirm plural and description with the user as well. 3. Call this tool to create the object 4. Immediately call `create-or-update-fields` with the returned entity identifier to add fields ## Description guidance The description field is stored as AI context for this object type, directly influencing how AI understands and works with records of this type. A good description captures what business concept this object represents and how the team uses it. Before creating a new object, if the user has not provided a description, ask them for one. Work with them to craft a description that captures the object's purpose. Only proceed without a description if the user explicitly declines.

ActionTry it

Create-or-update-fields

Create new fields or update existing field metadata (name, options, AI config) on any entity (company, person, deal, meeting, or custom object). Does not support changing an existing field's type or nullable/required status. Also known as custom fields. Rename fields, add enum/select options (existing options are preserved; pass an empty options array to clear all), change option colors, and configure AI autofill. Supports all field types: text, number, currency, date, enum, multi-select, markdown, attachment, and relationships. You must use `read-context` with context="field-docs" before creating or updating fields to load field type guidelines, AI prompt writing instructions, and examples. ## Important notes - Field deletion is permanent. Clarify cannot restore or recover a deleted field. Never tell a user a deleted field can be brought back. - If a user asks to restore or undelete a field, explain it can't be recovered and offer to create a new field with that name instead: it will be a fresh, separate field. ## Board / Kanban views group by an existing field A board (Kanban) view groups records into columns by one specific enum field, usually the object's built-in `status` field, or the field configured for that board. Creating a new enum field (e.g. a custom `stage`) does NOT change which field the board groups by, and you cannot see a view's board-grouping configuration from here. - When a user wants records to move between board columns, set values on the field the board already groups by (commonly `status`), or ask the user which field the board should group by. Do not create a parallel new enum and tell the user the board will use it. - If records don't land in the expected column but the data reads back correctly, do not blame a display, refresh, or caching issue. The likely cause is that the board groups by a different field than the one you set.

ActionTry it

Create-or-update-list

Create or update a dynamic list: a saved view whose membership is defined by a SQL query. Dynamic lists are the only list type Clarify supports today. ## When to use this tool - To create a new list: omit list_id and provide title + sql - To update an existing list: provide list_id along with the fields to change Prefer creating a new list over editing an existing one. When the user is exploring, filtering, or slicing what they are looking at ("show me just X", "filter to Y", "without Z"), create a new list (omit list_id) instead of changing the list they are viewing. Editing a saved list changes it for everyone it is shared with. Only pass list_id when the user explicitly asks to change that saved list itself, and only pass title with a list_id when the user explicitly asks to rename it. A list is the right surface when the user wants a browsable, filterable set of records of one object type. If they want aggregations, charts, or a multi-part deliverable, that is a report or dashboard (`create-or-update-artifact`), not a list. If the user explicitly asked for a list, build it without asking; if they asked for a report but the result is really just a list of records, offer the list and explain the distinction first. ## If the user asks for a "static list" or to manually curate a list Static lists are deprecated. Clarify no longer offers them as a list type, although some legacy static lists may still exist in the workspace. Do not call this tool to create one. Tell the user static lists are no longer supported, then offer the workaround: add a label to the records they want to track, and create a dynamic list that filters on that label. All built-in entities (companies, people, deals) have a built-in `labels` field with color-coded user-defined values, so the user can add or remove the label to control list membership manually. ## SQL restrictions This tool supports a restricted SQL subset. The query is used for both display and counting (via COUNT(*) wrapping), so certain patterns are incompatible: - **SELECT ***: Use explicit, table-qualified column names (e.g., "SELECT person.name, person.email_addresses FROM person") - **DISTINCT ON**: Not compatible with COUNT(*) wrapping. Use GROUP BY for deduplication instead. - **CTEs (WITH ... AS)**: Not supported. Rewrite using JOINs or WHERE clauses. - **Subqueries in FROM**: SELECT ... FROM (subquery) is not supported. Query the table directly with WHERE/JOIN clauses. - **LIMIT / OFFSET**: Not supported. A dynamic list is a continuously-evaluated membership filter, not a ranked snapshot; the LIMIT is dropped during evaluation, so a "top N" query silently matches every qualifying record. To bound a list, narrow membership with a WHERE clause (e.g. a recency window like "meeting.start > NOW() - INTERVAL '30 days'") or have the user add a label and filter on it. ORDER BY is fine; it sets the default sort, not membership. - Column names must be table-qualified (e.g., "person.name" not just "name") - Aggregate functions in ORDER BY or HAVING must also appear in the SELECT clause - **Filtering labels / multi-select fields**: these store JSONB as `{items: string[]}`. Filter membership with the array-overlap operator, e.g. `(person.labels -> 'items') ?| ARRAY['BDR Prospect']`. Do not use the `@>` containment operator; it works in the database but the list UI cannot render or edit a filter built with it. ## Filtering by the current user To make a list relative to whoever is viewing it (e.g. "my tasks", "deals I own"), compare against current_setting('app.current_user_id', TRUE), which resolves to the viewing user's ID at query time so the list works for every viewer. - Use it directly in a WHERE clause: WHERE task.assignee_id = current_setting('app.current_user_id', TRUE) - Do not hardcode a specific user's ID, and do not use template tokens like {{current_user_id}}; they are not substituted and the list will match nothing. ## Important notes - Only the fields you provide will be updated; omitted fields remain unchanged - The tool validates both the main query and count query to ensure the list will work when opened - Creating a new list publishes it immediately. Updating an existing list (passing list_id with a new sql) does not publish: the change is saved as a draft version and stays not-live until the user applies it. Relay the apply link from the tool response and tell the user to review and apply it with "Save for everyone"; do not tell the user the update is already live. Metadata-only edits (title, description, emoji) apply immediately. ## Column choice for list views Lists render one row per root record, so every SELECT column must belong to the root entity. - The record-title column (the primary field, aliased `<entity>:__object__`) is always shown and cannot be removed through the list query; the backend re-adds it on save. Do not tell the user you removed the title column. Which columns are visible and their order is a per-view UI setting the user controls from the list header's column menu, not something this tool changes. - Prefer scalar fields on the root entity (e.g. `person.name`, `person.email_addresses`). - Do not select the record's `_id` (shown as "Record ID") column unless the user explicitly asks for it. The record identity is always available on the row; surfacing the raw UUID as a column is rarely useful and clutters the list. - Alias the title column onto the entity's **primary field**, never onto another column. `SELECT person._id AS "person:__object__"` renders every row blank and unclickable; `SELECT person.name AS "person:__object__"` is correct. Omitting the title column entirely is also fine — the backend adds it. - To show a to-one relationship as a column, join the related table aliased `<root>$<fk_column>` and select its primary field aliased `<root>$<fk_column>:__object__`, e.g. `SELECT "person$company_id".name AS "person$company_id:__object__" FROM "person" LEFT JOIN "company" AS "person$company_id" ON "person".company_id = "person$company_id"._id`. Do not select the bare `*_id` foreign key as a column: the grid matches columns against the schema's reachable fields, a raw foreign key is not one, and the column is silently dropped from the view. - Do not SELECT columns from many-to-many related tables. Joining through a join table produces duplicate rows, and the list view can't render an m2m relationship as a single cell. - If the user explicitly asks to display a many-to-many field as a column, explain that lists only show direct fields on the root record, and the full relationship is available on the record detail page. After creating or updating a list: 1. Tell the user what was created or updated in a friendly, conversational way 2. Provide a clickable markdown link to open the list (the tool response will include this) 3. If the tool response includes a prefilled Lead Finder link (returned when a new company or person list matches 0 records), relay it: offer it as the way to fill the list, using the exact markdown link from the response 4. Keep your response concise

ActionTry it

Create-or-update-records

Create or update records in Clarify. Supports bulk operations of up to 25 records per call. - **Without id**: Create a new record - **With id**: Update an existing record by ID This tool also links and unlinks relationships between records, not just attributes -- see the Relationships section below for trigger words and examples. All records in a single call must be the same operation: either all creates (no `id`) or all updates (with `id`). Split mixed batches into two separate calls. Each call is atomic: if any record in the batch fails, the entire batch is rolled back and no records are written. When working with user-supplied IDs (CSVs, pasted lists) verify the IDs exist with query-data first, or use smaller batches to limit the blast radius of a single bad ID. ## Before creating or updating records - Writable fields are not included in this tool's description. You must call get-schema with format "write" and only the entities you need before creating or updating records, unless the write schema for those entities was already loaded earlier in this conversation. Do not guess field names. - Never tell a user a field can be set before confirming it appears in the write schema. Computed / read-only fields (system-managed, e.g. interaction dates) are deliberately omitted from the write schema: they cannot be set here, and a field missing from the write schema is read-only or nonexistent, not something to recreate as a custom field. Do not offer to set such a field or to create a custom field of the same name. Explain that it updates automatically (from meetings and emails) and offer the real workaround, such as logging a meeting. - Before creating a company, deal, or person, use the query-data tool to search for existing records by name (ILIKE). Use a broad search, not a narrow ID filter. - If the search returns potential matches, present them to the user and ask whether to use an existing record or create a new one. - If no matches are found, proceed to create without asking for confirmation. - When creating a meeting, provide `title` (string), `start` (ISO 8601 datetime), and `end` (ISO 8601 datetime) in attributes. Set attendees via the `participants` attribute using the format `{ set: [{ email: "alice@example.com", name: "Alice" }, ...] }`. People, companies, and associations are created automatically from participants. - Meetings also support `summary` (markdown string for meeting summary/recap) and `notes` (markdown string for meeting notes or transcript). Pass these as plain markdown strings in attributes. - If the meeting data includes a timezone, pass it as the `timezone` attribute (IANA timezone string like "America/Chicago"). Naive datetimes in `start`/`end` will be interpreted in that timezone. If no timezone is provided, the user's preferred timezone is used. UTC datetimes (with Z or offset) are stored as-is. ## Relationships Pass all relationships (to-one and to-many) via the `relationships` parameter using this format: `[{ "relationshipFieldName": "<field>", "operation": "link" | "unlink", "targets": [{ "entity": "<entity>", "_id": "<id>" }] }]` Use `operation: "link"` or `operation: "unlink"` when the user says link, unlink, connect, disconnect, associate, dissociate, or relate -- any create/remove of a relationship between two records, for example: - "Link this deal to Acme Corp" -> link - "Unlink this contact from the deal" -> unlink - "Connect this deal to Acme Corp" -> link - "Dissociate this contact from the deal" -> unlink A relationship-only call is valid: `attributes` can be omitted (it defaults to `{}`) when only `relationships` is populated, e.g. to link or unlink a record without changing any other field. The specific relationship fields available per entity are returned by get-schema with format "write". ## Creating deals and tasks When creating a deal or a task, do not create a bare record with only a name. Fill in the fields that make it useful: inferring what you can and asking the user only for what you genuinely cannot. - **Owner / assignee defaults to the current user.** Unless the user names someone else, set a new deal's `owner_id` (and a new task's `assignee_id`) to the current user. Pass it via `relationships`, e.g. `{ "relationshipFieldName": "owner_id", "operation": "link", "targets": [{ "entity": "user", "_id": "<current user id>" }] }` (use `assignee_id` for tasks). The current user's record ID is in the "Information about the user" section of your context; MCP clients can get it from get-current-user. - **Infer before asking.** Before creating a deal, try to fill `amount`, `close_date`, and `stage`, and to link the relevant company and people, from context already available to you: recent meetings and emails with that company, and existing related records (use query-data and get-records). Link the named company (creating it first if it does not exist, per the notes below) and any clearly-relevant people. - **Ask only for the gaps.** If a field that matters can't be confidently inferred, ask the user for it in one short round rather than silently creating an empty record, and let them skip. Do not block creation if the user clearly just wants a quick record. - **Amount is a committed actual.** Never invent or estimate a deal's `amount`. If you don't have a committed, agreed value, ask the user or leave it unset; do not guess from projections. ## Collection fields (labels, multi-select, emails, etc.) Collection fields require an explicit operation. Do not pass raw arrays or { "items": [...] }. - **Append** (add values): `{ "append": ["value1", "value2"] }` - **Remove** (remove values): `{ "remove": ["value1"] }` - **Set** (replace all values): `{ "set": ["value1", "value2"] }` - **Append + Remove** (do both in one call): `{ "append": ["new"], "remove": ["old"] }` "set" cannot be combined with "append" or "remove". Use "set" alone to replace all values or clear them with `{ "set": [] }`. When creating records, use "set" for initial values: `{ "labels": { "set": ["Enterprise"] } }`. When updating records, choose the operation that matches the user's intent. If the user says "add label X", use "append". If they say "remove label X", use "remove". Before using "set" on an existing record, first query the record's current collection values so you know what you're replacing. "set" overwrites the entire collection; if you use it without checking, you will silently discard existing values the user wants to keep. ## Updating records When updating, you are modifying an existing record, not creating one. Do not re-run create-time logic or re-estimate values. - Only include a field when you have an explicit, new signal its value changed. No new signal for a field means leave it out of the call; do not re-send or re-estimate it. - Before overwriting a field that already has a value, read its current value and change it only if it genuinely changed. - Treat amount/value fields as actuals: set them only to committed, agreed values, never projections or estimates. - Do not move a deal's stage to a winning or final stage, or backward, without a clear signal the change actually happened. - For a calendar-synced meeting, a field sourced from the connected calendar event (e.g. title, start, participants) cannot be updated here; the tool returns an error naming the blocked field. Custom fields and any field not sourced from the calendar can still be updated. Ad-hoc meetings created in Clarify have no restricted fields. - Meetings support `summary` (markdown string for meeting summary/recap) and `notes` (markdown string for meeting notes or transcript). Pass these as plain markdown strings in attributes. These can be set on any meeting (ad-hoc or calendar-synced). - Attachments cannot be created here; a file must be uploaded through the app's attachment upload flow. You can update an existing attachment by `id` (e.g. to set custom fields), but its system fields (`s3_key`, `source`, `status`, and its record links) are read-only and will be rejected. ## Upsert mode (match_on) When creating records that may already exist, pass `match_on` with a unique field to match on (marked unique in the schema, e.g. `domains` for companies, `email_addresses` for people) to upsert instead of failing on duplicates. For each record, if exactly one existing record already has the same value for that field, the incoming attributes are merged into it and its ID is returned; records with no match are created. A value that matches multiple existing records errors; merge those records first. Scalar unique fields match exactly; collection fields match case-insensitively; non-unique fields are rejected. The whole batch still succeeds atomically. This avoids the re-query-and-retry dance after a duplicate error. `match_on` only applies to creates; do not combine it with records that have an `id`. Collection field operations (`set`/`append`/`remove`) are honored against the matched record, not flattened into a plain replace. ## Important notes - Rich-text fields shown as `markdown` in the write schema (for example a task's `description`, a deal's `summary`, or a meeting's `summary`) accept a plain markdown string in `attributes` and are converted to rich text automatically. Never open any rich-text/markdown field (`summary`, `prep`, `notes`, `description`, and any custom one) with a heading that restates or mirrors the field name; the field is already labeled in the UI; start directly with the content. - When setting numeric values (like deal amount), store the exact value the user provides. Never multiply or divide to convert between units (e.g. MRR to ARR). - Relationship fields (like `company_id`, `owner_id`, `people`) must be passed via `relationships`, not inside `attributes`. Use `attributes` only for scalar/non-relationship fields. - All relationships are optional. Records like tasks, deals, and people can be created or updated without linking to other records. Never require the user to provide a related record (e.g., a deal) before creating or updating a task. - When the user references a person or company that doesn't exist in the CRM, create the person/company record first using this tool, then link it to the main record. Do not ask the user to manually create the contact or provide unrelated context like a deal. - If you need to process more than 25 records, call this tool multiple times with batches of up to 25. - For a single record, use a single-element array. - After creating or updating records: 1. Tell the user what was done in a friendly, conversational way 2. Refer to records by name only. 3. Never include a record URL in prose; the tool result already carries each record's link.

ActionTry it

Create-or-update-workflow

Create a new workflow or update an existing one. - **Without workflow_id**: Create a new workflow with a trigger - **With workflow_id**: Add, update, or delete blocks within a workflow, or update its description ## Before creating a new workflow (no workflow_id) Do not call this tool to create a new workflow yet. For any automation request (including one where the user says "workflow"), an agent is the recommended default. First recommend creating an agent and ask the user whether they'd prefer that, then wait for their answer. Do not read workflow docs or gather workflow details until they reply. Only create a workflow if they explicitly confirm they need a workflow and not an agent. (This gate applies to create mode only; updating an existing workflow via workflow_id does not require re-asking.) Important: Must use `read-context` with context="workflow-docs" for details on workflow format and workflow code support ## When to use this tool - Add new steps to a workflow - Change the configuration of a workflow step - Remove steps from a workflow - Update input fields for a block - Modify block parameters based on user feedback - Update the workflow description ## Create mode (no workflow_id) Provide name, description, and trigger_plugin_id. After creation, call this tool again with the workflow_id to add action blocks. ## Update mode (with workflow_id) - **Add a block**: Provide plugin_id and input. Optionally specify insert_after to control position. - **Update a block**: Provide block_id and input with the fields to change. - **Delete a block**: Provide block_id and set delete_block to true. - **Update description only**: Provide workflow_description without block_id or input. - **Update trigger input**: Set block_id to the trigger ID and provide input. - **Change trigger type**: Provide trigger_plugin_id (and optionally trigger_input) to replace the trigger plugin. Filters are cleared because they reference plugin-specific event paths. - **Update trigger filters**: Include `trigger_filters` in any call: alone, combined with `workflow_description`, or alongside a block add/update/delete. The array **replaces** all existing filters; pass `[]` to clear them. ## Code blocks (`code:execute`) The `code:execute` block takes a two-field input. `runtime` is fixed; only `code` varies: ```json { "plugin_id": "code:execute", "input": { "runtime": "clarify-nodejs@0.0.1", "code": "export async function handler(input, { clarify, _, workflowContext }) { /* ... */ }" } } ``` Pass the handler source as a raw JS string; no markdown fences. See the `workflow-docs` context for the SDK surface, execution constraints (30s timeout, allowed packages), and error semantics. **IMPORTANT**: Whenever you add, update, or delete blocks (or change the trigger), check whether the workflow's existing description still describes the flow. If your change makes it stale or incomplete, pass an updated `workflow_description` in the same call (no need to ask first) and briefly mention that you updated it. Leave the description untouched when it still fits.

ActionTry it

Delete-agent

Delete an agent by its ID. ## When to use this tool - When the user asks to delete or remove an agent they created ## Important notes - This action is permanent and cannot be undone - Only the creator of an agent can delete it

ActionTry it

Delete-artifact

Delete an artifact by its ID. An artifact is a named, versioned report or document that the user can view and revisit. ## When to use this tool Use this when the user asks to delete or remove an artifact (a report or document) they no longer want. You need the artifact's ID — look it up first if you only have its name. ## How it works Deletes the artifact along with all of its saved versions. This is permanent and cannot be undone. The user is asked to confirm before the artifact is removed.

ActionTry it

Delete-calendar-event

Cancel a calendar event. ## When to use this tool Use to cancel an event the user owns or has edit access to. For events the user is only invited to, use respond-to-calendar-event instead. ## Important notes - For recurring events, this cancels the whole series. - `notify_participants` defaults to `true` so attendees see the cancellation. - The cancellation runs as soon as this tool is called, with no confirmation step. Confirm with the user first.

ActionTry it

Delete-campaign

Delete a campaign by its ID. ## When to use this tool - When the user asks to delete or remove a campaign ## Important notes - This action is permanent and cannot be undone - The campaign must be disabled (draft) before it can be deleted - Only the campaign owner or an admin can delete a campaign

ActionTry it

Delete-custom-object

Delete a custom object type by its entity identifier. ## When to use this tool - When the user asks to delete or remove a custom object type ## Important notes - This action is permanent and cannot be undone - All records of this type will be deleted - Only custom objects (entity starting with "c_") can be deleted

ActionTry it

Delete-fields

Delete one or more custom fields from an existing object (built-in entities like person, company, deal, or custom objects like c_my_object). ## When to use this tool - When a user wants to remove custom fields they no longer need - When cleaning up unused or obsolete fields from any object ## Important - This is a destructive operation: deleted fields and their data cannot be recovered - Always confirm the deletion plan with the user before calling this tool - Protected (system) fields cannot be deleted - Relationship fields are deleted on both sides automatically

ActionTry it

Delete-list

Delete a list (saved view) by its ID. ## When to use this tool - When the user asks to delete or remove a list they created ## Important notes - This action is permanent and cannot be undone - Default lists cannot be deleted - The last remaining list for an entity cannot be deleted - Only the list owner can delete a shared list

ActionTry it

Delete-records

Delete one or more records by their IDs. Supports bulk deletion of up to 25 records per call. ## When to use this tool - When the user asks to delete or remove specific records ## Important notes - This action is permanent and cannot be undone - All relationships involving the deleted records are also removed - Use only if you already have the IDs for the records - If you need to delete more than 25 records, call this tool multiple times with batches of up to 25.

ActionTry it

Delete-workflow

Delete a workflow by its ID. ## When to use this tool - When the user asks to delete or remove a workflow ## Important notes - This action is permanent and cannot be undone - The workflow will stop executing after deletion - Only the workflow owner or an admin can delete a workflow

ActionTry it

Find-leads

Search Clarify's built-in prospect database of companies and people to find new leads matching criteria like industry, location, headcount, job title, seniority, and funding stage, and save (publish) a search the user wants to keep. Use this for prospecting and lead discovery on any page where leads, prospects, or outbound targets are discussed. Under the hood, a search is created as a draft (or draft version) from a custom-dialect SQL query - follow the rules and examples below exactly. ## Modes: draft vs publish This tool has two modes, set by the `mode` parameter (default `draft`): - `draft`: run a SQL query to create a new draft search, or a draft refinement of an existing one, and preview the matches. This is the default and covers all prospecting and refinement. - `publish`: save an existing draft search so it appears under the user's "My saved searches". Call with `mode: "publish"`, the search's `search_id`, and (when the search has an unapplied refinement) its `version_id`. No `sql` is needed to publish. ## Draft vs saved searches A `draft` result is **not** a saved search: it doesn't appear under the user's "My saved searches" until it's published. Never tell the user a search you just created is "saved"; describe it as a draft. If the user asks whether it's saved, say it's a draft and offer to save it. When the user asks to save (or "publish") a search, call this tool again with `mode: "publish"` and the search's `search_id`. ## Interpreting the result count The number returned is an upper bound on matching rows in the prospect database. Some of those rows may already exist in the user's CRM and some may overlap with each other. The actual number of net-new importable records can be lower and is only known at import time. Never present the match count as "unique importable leads" or use it to estimate credit spend — use it only to describe how many rows match the filters. ## Important Rules - Parameters like version_id and search_id should preferably come from the document context, which is the source of truth for identifiers. If the document context does not include a version_id, you may use the most recent version_id from the conversation if it is contextually relevant. ## When to use this tool Use this tool when a user wants to find leads matching specific criteria and preview them as a draft search: - Construct a SQL query following the rules below - Provide a `search_label` parameter (e.g., "SF Companies with 50+ employees") - The tool creates a draft search or draft version that the user can see and interact with (see "Draft vs saved searches" above) ## When not to use this tool Do not call this tool when the user's intent is ambiguous between existing CRM data and new leads. When the user searches for companies or people without using the word "lead" or "prospect", and the conversation does not already establish context, you must ask the user to clarify before calling any tool. Ask whether they want to find new leads/prospects to add to the CRM, or look up existing companies/people already in the CRM. Only call this tool after the user confirms they want new leads. ## Versioning vs Creating Searches When the user wants to update/modify a search: 1. Always use the SQL from the referenced version as the base query and only modify what the user explicitly asks to change. 2. Keep the same entity type and update the SQL query and search_label Update (provide search_id) when user says: "no", "actually", "refine", "filter", "change", "update", "modify" or refers to "it"/"that search"/"the search" Create (omit search_id) when user explicitly asks for a "new search" or different entity type (company vs person) ## SQL Rules 1. Use ILIKE, all matching is case-insensitive. 2. Database may convert ILIKE `'%foo%'` into `'foo%'` pattern (prefix-only pattern matching) depending on field type (full text search vs keyword matching). Names are processed with prefix matching only. 3. No SELECT * — explicitly list all columns. 4. Always have the first column be the primary object field with the alias `table_name:__object__`. 5. Never select _id or id fields — they are not queryable. 6. Always prefix columns with table names: `tam_company.name`, not `name`. 7. Only select top-level columns — nested JSONB fields are not allowed in SELECT clauses. 8. All selected columns require aliases in the format "table_name:column_name". 9. For JSONB array fields (industries, domains, etc.), use this exact syntax to filter: `(table.field -> 'items') ?| ARRAY['Value1', 'Value2']` Do not use CONTAINS, the `@>` containment operator, or standard SQL array syntax - only the exact pattern above works. 10. Before applying filters to enum fields, review the allowed values listed in "Available Tables and Columns" and include all relevant matches in a single WHERE clause. 11. When filtering by role or title, prefer `job_title ILIKE '%keyword%'`; it matches the person's actual title and covers most queries (e.g., "founders", "CTOs", "account executives", "sales managers", "engineers"). The `seniority` and `function` fields are coarse categorical enums with very few values; only use them when the user's query maps exactly to an enum value and a broad categorical filter is clearly intended. 12. Never use `ILIKE ANY(ARRAY[...])` or `= ANY(ARRAY[...])` — ARRAY/ANY is not valid here. To match any of several title keywords, OR together separate `job_title ILIKE '%keyword%'` conditions; to match any of several exact enum values, use `IN (...)` on that column. 13. Never include `ORDER BY` in find-leads SQL. The TAM database returns errors when ORDER BY is present, even on sortable fields. If the user uses words like "recently", "latest", or "top", interpret them as filters (e.g. `latest_role_change_at >= '2026-01-01'`), not as a sort instruction. Never write `ORDER BY`, regardless of which column the user mentions. 14. If the user asks to sort or rank explicitly, explain that find-leads cannot sort results and offer to filter by date or another field instead. 15. NOT with JSONB array operators (?|) is unreliable and returns zero results. Use positive filters instead of negation (e.g., filter for the industries you want rather than excluding ones you don't). 16. Never use OR to group multiple `->>'key'` conditions — it corrupts parentheses around JSONB access. Instead, use `IN` on a single key. Correct: `WHERE table.primary_location->>'city' IN ('San Francisco', 'New York', 'Boston')` Incorrect: `WHERE (table.primary_location->>'city' = 'San Francisco' AND ...) OR (table.primary_location->>'city' = 'New York' AND ...)` This applies to all JSONB object fields (primary_location, company_location, name), not just locations. 17. Tech-stack / tooling criteria (e.g. "uses Kubernetes", "on PostgreSQL") are a COMPANY attribute: filter `tam_company.tech_stack_products` with the JSONB-array syntax `(tam_company.tech_stack_products -> 'items') ?| ARRAY['Kubernetes']`. Never filter tech stack via a person's `skills` field. Because queries are single-table (no JOINs), a tech-stack criterion means the query must target tam_company; you cannot filter tam_person by a company's tech stack. 18. Never filter on `smart_tags` — it is not a reliable structured filter. Map every criterion to a structured column (job_title, seniority, function, employee_range / company_employee_range, tech_stack_products, industries, primary_location). 19. Single-table queries only — JOINs are not supported. 20. When creating a new search, use the default query for the entity as the base and only add a WHERE clause. When refining an existing search, preserve the existing SELECT columns unless the user explicitly asks to add or remove columns. 21. Never filter by tam_person email fields (email_addresses, personal_email_addresses) in WHERE clauses — these fields are masked until leads are imported into the CRM, so filtering by them would produce unreliable results. You may still SELECT them for display. ## Company Name Matching The TAM database stores **parent company names**, not subsidiary or regional office names. When users provide subsidiary names (e.g., "Globex France S.A.R.L.", "Initech Solutions Japan K.K."), extract the core company name and use prefix matching: Correct: `tam_person.company_name LIKE 'Globex%'`: matches "Globex", "Globex Inc.", "Globex France S.A.R.L." Incorrect: `tam_person.company_name = 'Globex France S.A.R.L.'` — exact match will likely miss the record Incorrect: `tam_person.company_name IN ('Globex France S.A.R.L.', ...)` — exact subsidiary names rarely exist in the database When matching multiple companies, use OR with LIKE prefix patterns on the core name: ```sql WHERE ( tam_person.company_name LIKE 'Globex%' OR tam_person.company_name LIKE 'Initech%' OR tam_person.company_name LIKE 'Stark Industries%' ) ``` **Important**: Because only prefix matching is available, short company names (e.g., "Nova") may produce false positives (e.g., matching "Novartis"). Use the most distinctive prefix possible. If the user provides both a short name and a more specific variant, prefer the longer one (e.g., `'Nova Dynamics%'` over `'Nova%'`). ## Examples Follow the SQL patterns in these examples exactly. The lead finder database uses custom JSONB syntax that differs from standard SQL. Example: Find me companies in SF with > 50 people ```sql SELECT tam_company.name AS "tam_company:__object__", tam_company.domains AS "tam_company:domains", tam_company.employee_range AS "tam_company:employee_range", tam_company.industries AS "tam_company:industries", tam_company.description AS "tam_company:description", tam_company.primary_location AS "tam_company:primary_location" FROM tam_company WHERE tam_company.primary_location->>'city' = 'San Francisco' AND tam_company.primary_location->>'state' = 'California' AND tam_company.employee_range IN ('51-250', '251-1K', '1K-5K', '5K-10K', '10K-50K', '50K-100K', '100K+') ``` Example: Find me SaaS company leads in New York ```sql SELECT tam_company.name AS "tam_company:__object__", tam_company.domains AS "tam_company:domains", tam_company.employee_range AS "tam_company:employee_range", tam_company.industries AS "tam_company:industries", tam_company.description AS "tam_company:description", tam_company.primary_location AS "tam_company:primary_location", tam_company.website AS "tam_company:website" FROM tam_company WHERE tam_company.primary_location->>'city' = 'New York' AND tam_company.primary_location->>'state' = 'New York' AND (tam_company.industries -> 'items') ?| ARRAY['SaaS'] ``` Example: Find me people who work in sales at tech companies ```sql SELECT tam_person.name AS "tam_person:__object__", tam_person.email_addresses AS "tam_person:email_addresses", tam_person.job_title AS "tam_person:job_title", tam_person.company_name AS "tam_person:company_name", tam_person.company_industries AS "tam_person:company_industries", tam_person.primary_location AS "tam_person:primary_location", tam_person.seniority AS "tam_person:seniority" FROM tam_person WHERE tam_person.function = 'Sales & Business Development' AND (tam_person.company_industries -> 'items') ?| ARRAY['Software Development', 'Information Technology & Services'] ``` Example: Find me founders in San Francisco Note: "founder" is a specific title — use `job_title ILIKE`, not seniority. ```sql SELECT tam_person.name AS "tam_person:__object__", tam_person.email_addresses AS "tam_person:email_addresses", tam_person.job_title AS "tam_person:job_title", tam_person.company_name AS "tam_person:company_name", tam_person.primary_location AS "tam_person:primary_location", tam_person.seniority AS "tam_person:seniority" FROM tam_person WHERE tam_person.job_title ILIKE '%founder%' AND tam_person.primary_location->>'city' = 'San Francisco' AND tam_person.primary_location->>'state' = 'California' ``` Example: Find me companies in San Francisco, New York, and Boston ```sql SELECT tam_company.name AS "tam_company:__object__", tam_company.domains AS "tam_company:domains", tam_company.employee_range AS "tam_company:employee_range", tam_company.industries AS "tam_company:industries", tam_company.description AS "tam_company:description", tam_company.primary_location AS "tam_company:primary_location" FROM tam_company WHERE tam_company.primary_location->>'city' IN ('San Francisco', 'New York', 'Boston') ``` Example of filtering by `tam_person.company_employee_range`: ```sql SELECT tam_person.name AS "tam_person:__object__", tam_person.email_addresses AS "tam_person:email_addresses", tam_person.company_employee_range AS "tam_person:company_employee_range", tam_person.company_location AS "tam_person:company_location" FROM tam_person WHERE tam_person.company_employee_range IN ('51-250', '251-1K') ``` ## Importing or adding leads to the CRM If user asks to add or import leads to the CRM, use the `import-leads` tool and not the create-or-update-records tool.

ActionTry it

Get-agent-runs

List an agent's runs, executions, invocations, and past run history, or fetch one run with its full transcript. Use this tool for any question about what an agent has done, when it ran, or how often it has fired. ## When to use this tool - "List runs for this agent" / "show me the agent's runs" - "Agent history" / "past runs" / "recent runs" / "last run" - "How many times has this agent run" / "count runs for an agent" - Debugging why an agent behaved a certain way: fetch the run to see the messages, tool calls, and responses that happened - The user provides an agent id and asks "show me what it did" ## Not the same as get-workflow-runs Agents and workflows both have runs but they live in different tables. Use this tool (not `get-workflow-runs`) whenever the subject is an agent or an agent id. Passing an agent id to `get-workflow-runs` silently returns nothing. ## How it works - Without `run_id`: returns a paginated list of recent runs for the agent - With `run_id`: returns the full run including its message transcript - A "run" is the chat session an agent produced when one of its triggers fired — user and assistant messages, tool calls, results, and errors ## Pagination - When the user asks for "more" runs / next page / older runs, call this tool again with the same agent_id and a larger `offset` ## Reading status and credits Each run reports a **Status** (e.g. "In progress", "Completed", "Failed") — use it, not credits, to judge whether a run has finished. **Credits Used** is a running cost total, not a progress indicator: a response that incurs AI cost adds at least 1 credit, so `0` credits means no such response has completed — the run may be not started, still generating its first response, or have failed before producing one. Never infer completion or a run's cost from the credit number alone; read Status for progress, and use the Created time to gauge how long a run has been running. ## Read the transcript before describing a run Whenever the user asks what a specific run did, or to summarize or explain one, call this tool with that run's `run_id` first. The list mode returns only counts and credits, never the messages, so describing a run from the list alone means inventing its content. Only the `run_id` transcript shows what happened. When the user reports that an agent isn't working as expected, proactively fetch the most recent run transcript before suggesting changes. Compare what happened in the run against the agent's instructions to diagnose the root cause. After diagnosing, explain what you found with specifics from the transcript. Suggest targeted fixes via `create-or-update-agent`, then offer to rerun.

ActionTry it

Get-agents

List agents visible to the current user, or fetch a single agent by ID. ## When to use this tool - When the user asks to see their agents or list available agents - When you need to look up an agent's instructions or configuration - When the user asks about a specific agent by name or ID ## How it works - Without agentId: returns all visible agents with description only - With agentId: returns the full agent details including complete instructions and configured triggers

ActionTry it

Get-artifact-component-doc

Get the full prop contract for one or more Clarify design-system components available to artifacts — each component's summary, a usage snippet, and every prop with its type and allowed values. ## When to use this tool The create-or-update-artifact tool lists the available `@clarify/ui/*` components with a one-line summary each. Call this before using components you are unsure about to get their exact props and allowed values, and call it when an existing artifact fails to render against a component so you can repair it against the current contract. ## How it works Pass one or more `specifiers` exactly as listed (e.g. `@clarify/ui/button`) — batch everything you need in a single call. Returns each component's documentation as markdown. Props are extracted from the component source, so the values are always current. ## Runtime dependency versions The artifact runtime pins `framer-motion` (v12.40.0), `react` (v19.2.8), `react-dom` (v19.2.8), `recharts` (v3.8.1). See the create-or-update-artifact tool for which of these your artifact JSX may import directly; the rest run internally (e.g. `react-dom` powers the scaffold's mount call). `@clarify/ui/*` itself is not independently versioned — it tracks this package.

ActionTry it

Get-artifact-source

Read the current source of an existing artifact — the JSX of its applied version, plus its name and description. ## When to use this tool Use this before updating an artifact so you patch its real current source rather than rewriting it from memory. It is the read half of the artifact edit loop: read the source here, apply your fix, then pass the full revised content to `create-or-update-artifact` with the same `artifact_id` to save a new version. It is especially useful when recovering from a render or query error reported to you for an artifact you did not author in this conversation. ## Upgrade while you're here The saved source may predate the current authoring guidance — an older artifact can hand-roll something a `@clarify/ui/*` component or hook now covers, or use query and chart code that has since improved. While you have the source open to make the requested change, compare it against the current component catalog and the query and chart guidance in `create-or-update-artifact`, and fold any clear upgrade into the same new version: swap a hand-rolled pattern for the component that now covers it, and bring stale query or chart code up to the current guidance. Change only what is a clear improvement — leave the rest of the source untouched. Then tell the user in plain terms that you upgraded it, without the build details, e.g. "I also updated the UI to the latest version for more features." ## Returns The applied version's JSX source (a React component named `ArtifactContents`), the artifact's name, and its description. Fails if the artifact does not exist or has no applied version yet.

ActionTry it

Get-calendar-events

List the current user's calendar events in a time range. ## When to use this tool - To answer "what's on my calendar today/tomorrow/next week" - To find a specific upcoming meeting by title or topic - To gather context (attendees, conferencing link) before drafting an email or creating a follow-up event ## How it works Reads from Clarify's synced calendar data: no live API call to Google or Microsoft. Filters meetings the current user attends in the given time range. ## Returns A list of events with both `event_id` (Nylas calendar event ID, used by the write tools) and `_id` (Clarify meeting ID, used by other meeting tools). When more events match than `limit`, the response notes the truncation. ## Important notes - Time range is required. Be reasonable: a 7-day window is a good default. - The optional `query` filter does case-insensitive substring matching on title and description.

ActionTry it

Get-campaign-recipients

List the individual people enrolled in a campaign, with their per-person engagement — who opened, who clicked, who replied, and when. ## When to use this tool - Answer "who clicked", "who opened", "who replied", "who hasn't responded" - Build a follow-up or retarget audience from the people who engaged with a campaign - Check where a specific person is in a campaign, or why their run stopped Use `get-campaigns` instead when you need campaign-level totals and rates rather than the people behind them. Get the `campaign_id` from that tool first. 📖 **For campaign rules and examples**: Use read-context with context: "campaign-docs" ## Returns One entry per recipient: person name, email, person ID, delivery status, opened/clicked/replied flags, and the timestamp of their most recent open, click, and reply. ## Pagination - When the user asks for "more" results, use the EXACT same inputs and only change the offset parameter - The tool will tell you the next offset value to use ## Examples <example> { "campaign_id": "0195f2c1-...", "event": ["clicked"] } </example> <example> { "campaign_id": "0195f2c1-...", "status": "completed", "limit": 25 } </example>

ActionTry it

Get-campaigns

List campaigns in the workspace, or fetch a single campaign by ID with full details. ## When to use this tool - Without campaign_id: discover existing campaigns, search by name, get campaign IDs - With campaign_id: read a campaign's full email content, inspect subjects/bodies/timing, check performance metrics, get details before duplicating with `create-campaign` 📖 **For comprehensive campaign rules and examples**: Use `read-context` with context: "campaign-docs" ## Returns **List mode** (no campaign_id): - Campaign name, ID, description, status, email step count, target list, created/updated info **Detail mode** (with campaign_id): - Full campaign details including all email steps with subjects, bodies (HTML with variables), and timing - Campaign-level performance: open rate, click-through rate, response rate, bounce rate - Per-email-step engagement: sent, opened, clicked, replied, bounced counts All engagement here is aggregate counts only. To find out *which people* opened, clicked, or replied — and when — use `get-campaign-recipients`. ## Pagination (list mode only) - When user asks for "more" results, use the EXACT same search inputs and only change the offset parameter - The tool will tell you the next offset value to use ## Duplicating a campaign To duplicate a campaign, call this tool with campaign_id first, then pass the returned email steps and settings to `create-campaign` with a new name like "Copy of <original>". Copy the `list_id` so the duplicate targets the same audience. Do NOT copy from_name. Let the new campaign use the current user's sender identity. If the original sender differs from the current user, also remove the original sender's personal sign-off from email bodies.

ActionTry it

Get-current-user

Get information about the current authenticated user, including their timezone. ## When to use this tool Use this to understand who "me", "my", and "I" refer to in user queries. ## Important notes When interpreting relative dates (e.g., "last 2 weeks", "yesterday"): - Calculate dates in the user's timezone returned by this tool - Convert to UTC and ISO format for database queries

ActionTry it

Get-lists

Get list metadata (saved views) for an entity type, or fetch a single list by ID. Only dynamic lists (membership driven by a SQL query) are returned. Legacy static and default lists are filtered out. ## When to use this tool - Without list_id: discover existing lists, search by name or description, or filter by creator or owner - With list_id: fetch full details for a specific list Supports case-insensitive substring search and filtering by creator (created_by) or owner (owner_id) when no list_id is provided. ## Returns - List name (title) - Description - SQL query - Layout (table, board) - Owner and creator (user name, email, and user ID) - Last edited (when a person last changed the list's query or layout, and who — or "No edits recorded"). This does not track title, emoji, or description edits. ## Pagination - When user asks for "more" results, use the EXACT same search inputs and only change the offset parameter - The tool will tell you the next offset value to use (e.g., "Call this tool again with offset=25 to continue")

ActionTry it

Get-records

Retrieve detailed information about specific records by their IDs. This tool provides comprehensive context about records, including relationships, AI summaries, and other details. Use this tool when you need to: - Understand the current state of specific records before answering questions or taking actions - Read meeting recording transcripts with full speaker attribution - Read email content, subject, body, and participants - Get rich context about records found via query-data Typical workflow: Use query-data to find record IDs, then pass them here for full details. Meeting-specific guidance: - For meeting: returns metadata, summary (AI-generated), notes (user-written), and associated recordings. If summary is null, check notes before concluding no content exists - For meeting_recording: returns the full transcript with speaker names and timestamps - When the user asks for exact quotes, verbatim wording, or "what exactly was said", fetch the meeting_recording transcript via get-records. The meeting summary is paraphrased and does not preserve the original words.

ActionTry it

Get-schema

Get the schema for Clarify entities. ## When to use this tool Use the read format when working with SQL queries and the query-data tool. Use the write format when creating or updating records with the create-or-update-records tool. Pass the specific entities you need. Read defaults to the CRM database, and omitting entities returns every CRM entity, a large result that is re-sent on every later turn, so only omit it when you genuinely need the whole model. For lead columns (funding stage, employee range, industries, location), request the tam_company or tam_person entities to get the schema for the find-leads tool. The analytics event log schema comes with the query-analytics tool directly, so you do not need this tool for it. ## Databases Clarify has three databases: - CRM database (company, person, deal, meeting, user, task): your workspace data. Use with the query-data tool. Read returns these by default. - Leads database (tam_company, tam_person): prospecting data for finding new leads. Request these entities explicitly. Use with the find-leads tool. - Analytics database (ClickHouse, single `analytics.event` table): historical event log of every CRM change: point-in-time and change-history questions. Its schema comes with the query-analytics tool. ## Formats - read: schema for reading data via SQL queries with the query-data tool, or lead columns for the find-leads tool. Returns all columns, types (including JSONB structure), relationships, join tables, and custom fields. Defaults to CRM entities. - write: writable fields for the create-or-update-records tool. Returns fields that can be set when creating or updating records.

ActionTry it

Get-workflow-runs

Get execution runs for a workflow. Returns run history with status and timing. ## When to use - Check if a workflow is executing correctly - Review recent run history - Count failures or investigate patterns ## Returns - Run ID, status, start/end times, duration - Record that triggered the run (if applicable) - To get full details of a specific run, use the get-records tool with entity "workflow-run" and the run ID ## Pagination - When the user asks for "more" results, use the same inputs and only change the offset

ActionTry it

Get-workflows

Get and search workflows in the workspace, or fetch a single workflow with its full configuration. When called without `workflow_id`, returns metadata for matching workflows (supports case-insensitive substring search and enabled/disabled filtering). When called with `workflow_id`, returns the full configuration for that one workflow (trigger with input and filters, every action block) and ignores the other inputs. ## When to use this tool - Find a workflow by name to get its ID - Discover existing workflows in the workspace - Get the workflow ID before calling `get-workflow-runs` or `create-or-update-workflow` - Confirm a workflow's trigger filters match what the user asked for (pass `workflow_id`) - Inspect a specific block's input or filters before editing it (pass `workflow_id`) - Walk the full block order (trigger → blocks → exits) to debug a misconfigured workflow (pass `workflow_id`) ## Returns Without `workflow_id` (list mode), for each workflow: - Workflow ID - Name and description - Status (active/draft) - Trigger plugin ID - Number of action blocks - Created and updated timestamps With `workflow_id` (single mode): - Workflow ID, name, description, status (active/draft), and timestamps - Trigger: plugin ID, input, filters - Each block: ID, plugin ID, input, filters, prev/next links ## Pagination When the user asks for "more" results, use the same inputs and only change the offset

ActionTry it

Import-leads

Import leads from a tam_company or tam_person search into Clarify. ## Important Rules - Parameters like versionId and searchId should preferably come from the document context, which is the source of truth for identifiers. If the document context does not include a versionId, you may use the most recent versionId from the conversation if it is contextually relevant. - When a versionId is provided, the version will be applied to the search and the search will be published before starting the import - Always provide a descriptive searchTitle that reflects the current filters/query of the search (e.g., "SF Companies with 50+ employees") - Always provide a searchEmoji — a single emoji that represents the theme of the search (e.g., "🏢" for companies, "🌉" for SF-related searches) When the user asks to import leads without specifying a count: - For tam_company searches, import all leads without asking — company imports never consume credits regardless of size. - For tam_person searches with fewer than 100 leads, import them all without asking. - For tam_person searches with 100 or more leads, ask whether they want to import a small sample (e.g. 10) to test first or all leads from the search. Inform the user that importing people costs 1 credit(s) per net-new person, so importing all N people will cost up to N × 1 credits total. ## How charges work Credits are charged only for net-new tam_person records that get inserted. Records that already exist in the CRM are skipped by the system and never charged. tam_company imports are always free. When a batch returns 0 net-new records (all matches already exist in the CRM), nothing is charged. Never tell the user they have "burned credits on duplicates" or suggest a refund in that case; there is nothing to refund. Never explain internal decision logic, thresholds, or tool behavior to the user. For example, do not say things like "Since there are fewer than 100, I'll import them all." Just perform the action and describe what you did in user-friendly terms. ## Extra fields This tool can import any field from the TAM database — not just the core fields. When the user asks for additional data (e.g., "import with industries and tech stack", "I want ownership status too", "add seniority"), use the extraFields parameter. The import automatically creates CRM fields that don't exist yet and populates them with TAM data. Do not create fields manually — this tool handles it. This also works for records that already exist in the CRM. If the user wants to backfill a field (e.g., "add industries to my existing companies"), re-run the import with extraFields and the existing records will be updated with the new field values. Available extra fields for companies: alternative_names (Alternative names), website (Website), type (Type), industries (Industries), specialities (Specialities), workforce_headcount (Workforce headcount), summary (Summary), funding_last_round_type (Last funding round type), funding_last_round_amount_usd (Last funding round amount (USD)), funding_last_round_date (Last funding round date), investments (Investments), financing_profile_status (Financing status), financing_profile_ipo_date (IPO date), financing_profile_market_cap (Market cap), ownership_status (Ownership status), ownership_status_detailed (Ownership status (detailed)), stock_exchange (Stock exchange), customer_types (Customer types), tech_stack_products (Tech stack products), patent_count (Patent count), contact_info_email (Contact email), contact_info_phone (Contact phone), contact_info_url (Contact URL), identifiers_stock_ticker (Stock ticker), identifiers_naics_code (NAICS code), identifiers_duns_code (DUNS code), identifiers_cage_code (CAGE code), is_acquired (Is acquired), is_exited (Is exited), is_government (Is government), is_non_profit (Is non-profit), is_shut_down (Is shut down), is_stealth (Is stealth). Available extra fields for people: personal_email_addresses (Personal email addresses), headline (Headline), function (Function), seniority (Seniority), hiring (Hiring), open_to_work (Open to work), skills (Skills), smart_tags (Smart tags), investor_data_type (Investor type), investor_data_geo_focus (Investor geo focus), investor_data_industry_focus (Investor industry focus). If the user says "import all fields" or "include everything", pass all available field names for the entity type. If the user doesn't mention extra fields, omit the parameter.

ActionTry it

Import-meeting-transcript

Import a meeting transcript from an external source (Granola, Notion, Circleback). ## When to use this tool Use after finding a meeting in an external source. Pass the source and transcript ID directly; the tool fetches the content from the source system. ## How it works 1. Provide the Clarify meeting ID to attach the transcript to 2. Specify the source (granola, notion, or circleback) 3. Provide the transcript/page ID from the source system The tool calls the source's MCP server directly to fetch the transcript, parses it, and uploads it to the meeting. ## Important notes - The workspace must have the source MCP server connected - The user must have authorized with the source MCP server - For Circleback: the source_id must be the numeric meeting ID (e.g. "7927294"), not the URL slug. Use the Circleback SearchMeetings tool first to resolve the numeric ID. - After importing, tell the user the transcript was attached

ActionTry it

Manage-access

Manage who can access a specific object — a list, meeting, message, or record (deal, company, person, or a custom object) — by granting, updating, revoking, or reading its access grants, or reassigning its owner. ## When to use this tool - When the user asks to share a list, meeting, message, or record with a teammate or with the whole workspace - When the user asks to share every email/message on a record (e.g. "share all emails on this deal with the team") — use `mode: "related"` - When the user asks to change someone's access level (view vs edit) - When the user asks to stop sharing / remove access - When the user asks who an object is shared with - When the user asks to change who owns a list or record — reassign ownership to someone else ## How it works Set `action` to one of "grant", "update", "revoke", "read", or "reassign" and point `target` at what you're managing. `target` has two modes: - `mode: "direct"` — a single object by `entity` (list, meeting, message, or a record object type such as deal/company/person/custom) and `objectId`. Supports every action. - `mode: "related"` — every message related to an anchor record, addressed by `anchorEntity` (e.g. deal, company, person), `anchorId`, and `relatedEntity: "message"`. Bulk-shares the messages you own on that record. Supports grant, revoke, and read only (no update or reassign). - grant / update: pass `grantees` and an `accessLevel` ("view" or "edit"). - revoke: pass either `grantees` or `all: true` (not both). Revoke asks the user to confirm before removing access. - read: returns the current grantees and their access levels. - reassign: pass `newOwnerId` (a user record id) to transfer ownership to that user. The previous owner keeps edit access. To reassign several objects to the same new owner at once, add the other object ids to `objectIds` (`target.objectId` is always included) — they transfer in a single confirmed call. ## Grantees A grantee is `{ granteeType, userId }`: - `granteeType: "user"` shares with one person and requires `userId` (a user record id — resolve names/emails to an id first). - `granteeType: "workspace"` shares with everyone in the workspace and takes no id. ## Important notes - Only the owner (or a workspace permission admin) can change an object's access or reassign its ownership; meetings require a participant. Unauthorized requests are rejected. - A workspace admin can reassign the owner of a record only when it is workspace-visible, never a private one. The owner can always reassign their own, and list ownership transfer has no such restriction. - Meetings and messages have no owner and cannot be reassigned. - In related mode, only the messages you own on the anchor record are shared; messages you don't own are never touched. - Access levels are "view" or "edit" only.

ActionTry it

Merge-records

Merge two or more records into a single primary record in Clarify. All data from the source records (fields, relationships, list memberships, notes) will be merged into the primary record. Source records are deleted after merging.

ActionTry it

Query-analytics

Execute read-only ClickHouse SQL against the analytics event log. ## When to use this tool Use `query-analytics` for **historical / point-in-time** questions about CRM records: anything that requires looking at the sequence of changes over time, not just the current state. Examples: - "What was the sequence of stage changes for this deal?" - "How long did this deal sit in 'In progress'?" - "What was the total amount of all deals in each stage on a specific date?" - "How many deals were created per day last week?" - "Who last edited this record, and when?" ## When NOT to use this tool For **current state** questions ("which deals are open?", "what's this company's domain?"), use the `query-data` tool: it queries PostgreSQL directly and is faster for live values. Only fall back to `query-analytics` when you need history. ## How it works - The query runs against ClickHouse, not PostgreSQL. Use ClickHouse SQL syntax (`JSONExtractString`, `toDate`, `argMax`, etc.). - When the user hasn't asked for a specific time frame, scope the query to the last 30 days with `AND timestamp >= now() - INTERVAL 30 DAY` to keep it fast and the results relevant. - Statements are read-only: INSERT / UPDATE / DELETE / DDL are rejected by ClickHouse. - Results are capped to the `maxResultRows` parameter (default 1024). Rows beyond the cap are dropped server-side, even if your SQL omits a `LIMIT`. # Analytics database (ClickHouse) Historical event log of every change to the CRM. **Read-only.** Query it to answer point-in-time and change-history questions like: - "What was the sequence of events that led to the current stage of this deal?" - "How long did this deal sit in the 'In progress' stage?" - "How many deals were created per day last week?" ## 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`. ### Time-in-stage Select the stage-transition events with the `diff` filter (see "Detecting when a field changed"), and `UNION` in each deal's `clarify:create` event so the first stage's clock starts at creation. Then, over that filtered set, take each transition's gap to the next with `leadInFrame(timestamp) OVER (PARTITION BY entity_id ORDER BY timestamp ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)` — that gap is how long the deal held the stage. The frame is required: `leadInFrame` defaults to a frame ending at the current row and would never see the next event. The latest transition has no next event, so its `leadInFrame` is the zero date — treat that as "still in this stage" (`now()`). Don't treat every `clarify:update` as a transition — every event repeats the current stage whether or not it changed. ## Examples <example> -- Deals created per day in the last 7 days SELECT toDate(timestamp) AS day, count() AS created FROM analytics.event WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal' AND type = 'clarify:create' AND timestamp >= now() - INTERVAL 7 DAY GROUP BY day ORDER BY day </example> <example> -- Stage changes for a single deal (only the events where the stage actually -- changed — a non-empty stage alone is not a change, since every event repeats -- all fields). `diff` lists the fields an update changed, so keep the events -- whose diff touched `stage`; the matching entry carries the old→new value. SELECT timestamp, m_stage AS stage, arrayFirst(d -> JSONExtractString(d, 'path', 1) = 'stage', JSONExtractArrayRaw(diff)) AS stage_change, JSONExtractString(stage_change, 'oldVal') AS from_stage, JSONExtractString(stage_change, 'val') AS to_stage, actor._id AS actor_id FROM analytics.event WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal' AND entity_id = '<deal-id>' AND type = 'clarify:update' AND arrayExists(d -> JSONExtractString(d, 'path', 1) = 'stage', JSONExtractArrayRaw(diff)) ORDER BY timestamp </example> <example> -- Stage-change events per day across all deals. `diff` records the fields each -- update changed, so a stage change = an update whose diff touched `stage`. No -- window function and no boundary caveat: each event is self-contained, so the -- timestamp range is exact. SELECT toDate(timestamp) AS day, count() AS stage_changes FROM analytics.event WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal' AND type = 'clarify:update' AND arrayExists(d -> JSONExtractString(d, 'path', 1) = 'stage', JSONExtractArrayRaw(diff)) AND timestamp >= now() - INTERVAL 90 DAY GROUP BY day ORDER BY day </example>

ActionTry it

Query-data

Execute PostgreSQL queries to retrieve data from Clarify. ## When to use this tool - Counting records (e.g., "How many deals are open?") - Aggregations (SUM, AVG, COUNT, MAX, MIN, GROUP BY) - List queries (e.g., "Show me 50 recent meetings") - Filtering by timestamps (e.g., deals created in Q4, meetings last week) - Searching/filtering large datasets (e.g., "Show all deals from Q4" or "Find records updated this week") ## For detailed record context, use get-records After finding record IDs with this tool, use get-records for: - Full record details, relationships, and AI summaries - Meeting recording transcripts with speaker attribution - Email content, subject, body, and participants - Example: query-data to find IDs, then get-records to read details ## When not to use this tool This tool queries existing CRM records. When users want to find new companies or people to add to the CRM (leads/prospects), search for the lead finder tools instead. - Do not use this tool when the user mentions "leads" or "prospects" and wants to source new contacts (e.g., by industry, location, employee count, job title). Search for the lead finder tools that query the lead database instead. - Still use this tool when the user mentions "leads" or "prospects" but the context clearly refers to existing CRM records (e.g., "leads in my pipeline", "prospects with last product activity in last 30 days"). Activity history, emails, meetings, and pipeline stages only exist for CRM records, not in the lead database. But the user is still asking about leads/prospects, so we still use this tool. - Ask the user to clarify when they search for companies or people without using the word "lead" or "prospect", and the conversation does not already establish a CRM context. Ask whether they want to find new leads/prospects to add to the CRM, or look up existing companies/people already in the CRM. If the conversation already establishes CRM context, use this tool without asking. ## Before writing SQL - This tool does not include any schema. Workspaces have custom fields and JSONB columns that vary per workspace, so you cannot know the column names without loading the schema first. Always call get-schema with format "read" and the entities you need before writing SQL, unless the schema for those entities was already returned by get-schema earlier in this conversation. - Call get-current-user if the query involves "me", "my", or "I", if the tool is available - Always prefix columns with table names (e.g., `person.name`, not `name`) - Use ILIKE for case-insensitive matching (not LIKE) - Query timestamps using UTC/ISO 8601 format (e.g., '2025-12-22T00:00:00Z') - Results will have timestamps formatted in the user's timezone for readability - For many-to-many relationships, use INNER JOIN with join tables ## Working with JSONB Columns Many columns store data as JSONB objects. Check the schema returned by get-schema to identify JSONB columns. Only use JSONB operators (->, ->>) on columns explicitly marked as "JSONB with format" in the schema. ### JSONB Access Operators - `->` - Extract JSON object (returns JSONB): `column->'key'` - `->>` - Extract text value (returns TEXT): `column->>'key'` - `jsonb_array_elements(column->'items')` - Iterate over JSONB arrays ### JSONB Column Patterns Object with nested fields (e.g., `JSONB with format {first_name: string, last_name: string}`): - Access nested field: `column->>'first_name'` - Concatenate fields: `CONCAT(column->>'first_name', ' ', column->>'last_name')` - Filter: `column->>'first_name' ILIKE '%john%'` Array of items (e.g., `JSONB with format {items: string[]}`): - Get first item: `column->'items'->>0` - Iterate items: `jsonb_array_elements(column->'items') as item` - Access nested in item: `(item->>'email')` ### Filtering multi-select and label arrays For columns with format `{items: string[]}` (multi-select enums, the built-in `labels` field, etc.), filter membership with the array-overlap operator `?|`: - Has any of: `(person.labels -> 'items') ?| ARRAY['BDR Prospect', 'ICP']` Do not use the `@>` containment operator or `CONTAINS`. `@>` works in the database, but a list built with it cannot be rendered or edited in the list UI; always use the `?|` pattern above. ### Checking if JSONB collection fields are empty For columns with format `{items: string[]}` (multi-select enums, email addresses, etc.), an empty field can be either NULL or `{"items": []}`. Always check for both: - Is empty: `(column IS NULL OR (column->'items') = '[]'::JSONB)` - Is not empty: `(column IS NOT NULL AND (column->'items') <> '[]'::JSONB)` Do not use only `IS NULL` or `IS NOT NULL` for these fields; that misses records with empty arrays. ## Time-Based Queries Use the current time from `get-current-user` tool or system context as reference. ### "Last" vs "Next" Terminology - "last meeting" = Most recent past meeting: `WHERE meeting.start < NOW() ORDER BY meeting.start DESC LIMIT 1` - "next meeting" = Soonest future meeting: `WHERE meeting.start > NOW() ORDER BY meeting.start ASC LIMIT 1` - "recent meetings" = Meetings in the past, not upcoming ones - "upcoming meetings" = Meetings in the future ### Time Period Interpretation When user mentions "Q4 2025", "last quarter", or similar: - Activity in that period: Filter by date: `WHERE meeting.start >= '2025-10-01' AND meeting.start < '2026-01-01'` - Content mentioning that period (e.g., "meetings where Q4 was discussed"): Search transcripts/summaries, not date filter ## User-Scoped Queries When querying data related to "me", "my", or "I": - Use the current user's ID to filter records - Remember: `user` and `person` are different tables - `user` = internal workspace members, `person` = external contacts - For meetings: `INNER JOIN user_meeting ON meeting._id = user_meeting.meeting_id WHERE user_meeting.user_id = '<user-id>'` - External meetings have attendees who are not workspace users. Check `person_meeting` but exclude persons whose email matches a user: `EXISTS (SELECT 1 FROM person_meeting pm JOIN person p ON p._id = pm.person_id WHERE pm.meeting_id = meeting._id AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.email IN (SELECT jsonb_array_elements_text(p.email_addresses->'items'))))` - Internal meetings have no truly external attendees (inverse of above) - For tasks: Filter by `assignee_id`: `WHERE task.assignee_id = '<user-id>'` - For deals: Filter by `owner_id`: `WHERE deal.owner_id = '<user-id>'` ## Deal Analytics ### Comparison operators for thresholds When users reference a threshold amount: - "above $300", "over $300", "at least $300": `deal.amount >= 300` - "below $500", "under $500": `deal.amount < 500` - "more than $300", "fewer than 5": use strict comparison (`>` / `<`) ### Time-bounded deal metrics When users ask about deals in a specific time period (e.g., "January deals", "Q4 closed deals", "deals closed this month"), always filter by `close_date` within that period in addition to any stage filter. Do not filter only by stage -- that returns all historically closed deals, not deals closed in the requested period. Only add a closed stage filter when the user is asking about closed or won deals specifically. Do not add a closed stage filter for queries about open or in-progress deals. - Example: "deals closed in January 2026" requires `deal.stage IN (<closed stages from schema>) AND deal.close_date >= '2026-01-01' AND deal.close_date < '2026-02-01'` - Example: "deals expected to close in Q1" should only filter by `close_date` without a closed stage constraint ## Association Queries When finding emails or meetings "related to" a company or entity, join through the relationship graph instead of searching message body text. - Emails related to a company: JOIN person_message with person WHERE person.company_id = <company_id>. Do not use raw_body ILIKE '%company name%' -- body text matching picks up incidental mentions and links unrelated contacts. - Meetings related to a company: JOIN person_meeting with person WHERE person.company_id = <company_id>. - Emails for a specific person: JOIN person_message WHERE person_message.person_id = <person_id>. - Only use body/subject text search when the user explicitly asks to search email content (e.g., "find emails mentioning Project X"). ## Activity Counting Activity includes: meetings, messages, tasks, and comments. To count activity: - Comments: Query `comment` table with `entity = '<entity-type>'` and `owner_id = <entity>._id`. Use `_created_at` as timestamp. - Meetings: Join through `person_meeting` join table. If entity relates to `person` via join table (e.g., `person_deal`) or foreign key (e.g., `person.company_id`), join: entity → person → `person_meeting` → `meeting`. Use `meeting.start` as timestamp. - Messages: Join through `person_message` join table. Same pattern as meetings: entity → person → `person_message` → `message`. Use `message.received_at` as timestamp. - Tasks: Join via foreign key `task.deal_id`. For deals: direct join. For companies: join `deal` first (via `deal.company_id`), then `task`. Use `task._created_at` as timestamp. Pattern: Create CTEs for each activity type, UNION ALL them, then LEFT JOIN to your entity table. Use COUNT() and MAX() for totals and last activity date. ## Comments Comments are notes left on records. The comment table has no schema in get-schema: use these columns directly: - `_id`: UUID primary key - `entity`: parent entity type (e.g., 'company', 'deal', 'person', 'meeting', 'task') - `owner_id`: parent record ID - `message`: JSONB rich text; use `message::text` for raw content - `_created_by`: user ID of the comment author. JOIN with `"user"` on `_created_by = "user"._id` for author name - `_created_at`: timestamp ## Existence Checking Before creating a record, search broadly by name to check if it already exists. Duplicate records with the same name but different IDs are common. - Use ILIKE name matching: WHERE deal.name ILIKE '%Snowplow%' or WHERE company.name ILIKE '%Snowplow%'. Do not rely solely on a single foreign key (e.g., WHERE deal.company_id = '<id>') -- this misses records linked to duplicate/variant company records. - When checking for existing deals, search by both the deal name and the related company name. ## Name search strategies When a name search returns no results, try variations before asking the user to clarify. Records are often stored differently than expected (e.g., "BrightLoop" vs "Bright Loop"): - Remove spaces: `ILIKE '%brightloop%'` - Search individual keywords: `name ILIKE '%bright%' OR name ILIKE '%loop%'` - For a person's first name, also try common nickname/diminutive variants (e.g. Tim/Timothy, Bob/Robert, Liz/Elizabeth, Bill/William, Mike/Michael) and a prefix match (`name->>'first_name' ILIKE 'Tim%'`) before concluding no match exists - If a broad search returns many results, pick the closest match rather than asking the user to choose Before matching a name column with ILIKE, check its type in the get-schema output. A name stored as a JSONB object (shown as `JSONB with format {...}`) is not text: a bare `name ILIKE` on it fails with `operator does not exist: jsonb ~~*`. This is common for the display name on people-type entities (for example a person or workspace user whose name is `{first_name, last_name}`). Extract the text first, e.g. `name->>'first_name' ILIKE '%...%'` or `CONCAT(name->>'first_name', ' ', name->>'last_name') ILIKE '%...%'` (see "Working with JSONB Columns" above). Only columns the schema marks scalar (e.g. a plain `string` email) work with a direct ILIKE. ## Meetings Meetings are personal data, like emails: a meeting belongs to its participants (the users with a `user_meeting` row), not to the whole workspace. Meeting content can be sensitive, so scope meeting queries to the current user by default. - Default: return only the current user's own meetings by INNER JOINing `user_meeting` (see User-Scoped Queries for the exact join). This applies to bare requests with no other anchor -- "today's meetings", "recent meetings", a daily/EOD digest -- exactly as you filter emails to the current user. - Only query across all users' meetings when the user explicitly asks for the team's or workspace's meetings. - Meetings anchored to a specific company, person, or deal are the exception: join through `person_meeting` (see Association Queries) instead of `user_meeting`. ## Meeting Recordings & Transcripts Meeting recordings are linked to meetings via meeting_recording.meeting_id. The meeting_recording table is queryable via SQL (use entity=meeting for the query): - Find recordings: SELECT meeting_recording._id FROM meeting_recording WHERE meeting_recording.meeting_id = '<meeting-id>' - Read transcript: use get-records with entity=meeting_recording and the recording IDs A meeting can have multiple recordings. To avoid duplicate rows, filter with an EXISTS subquery instead of JOINing meeting_recording directly: SELECT meeting._id, meeting.title FROM meeting WHERE EXISTS (SELECT 1 FROM meeting_recording WHERE meeting_recording.meeting_id = meeting._id) When listing or showing meetings (e.g., "show me my last 5 recorded meetings"): - Always include summary and/or notes in the SELECT columns, not just metadata (title, date, status) - Present the summary content inline so the user sees what each meeting was about - If summaries are available, offer to show full transcripts for any specific meeting When answering questions about a single meeting (summary, follow-up email, action items): - Check for recordings and read the transcript via get-records before responding When answering questions across multiple meetings: - Prefer using the summary and notes fields first (available on the meeting record) - Only read individual transcripts if the user explicitly asks or summaries are insufficient Meeting content fields: - "summary": AI-generated meeting summary (on meeting record) - "notes": User-written meeting notes (on meeting record, separate from summary) - If summary is null, check notes before concluding the meeting has no content A substring match inside a summary is not evidence that a topic was requested or discussed. Summaries often enumerate ABSENT topics ("No mention of dialers, enrichment, or reporting tools"), so an ILIKE '%dialer%' hit can be a negation. Before attributing a request or interest to a person or meeting from a summary match, read the matching summary text and confirm the term appears in an affirmative context. Do not count a negated mention as a positive signal, and do not report a corrected count as fact until you have re-read the source. ## Emails & Messages The message table contains sent and received emails. Use entity=message to query directly. Sensitive columns (raw_body, subject) are protected by database-level RLS; only messages the current user has access to will return content. Use get-records with entity=message for full formatted email content with participant details. Filter by user_id for the current user's emails, received_at for time ranges, and person_message join for specific contacts. Never claim you cannot access emails -- sent and received emails are queryable. Unsent drafts are not. ## Pagination - When user asks for "more" results, use the EXACT same SQL query and only change the offset parameter - The tool will tell you the next offset value to use (e.g., "Call this tool again with offset=25 to continue") ## Data Limitations Tables contain current field values only. You cannot determine what a field's value was at a past date with this tool. What you can answer: - Deals currently in stage X: `WHERE deal.stage = 'In progress'` - Deals created/updated in a period: `WHERE deal._created_at >= '2025-10-01'` - Deals closed in a period: `WHERE deal.stage IN (<closed stages from schema>) AND deal.close_date >= '2025-10-01' AND deal.close_date < '2025-11-01'` (see "Time-bounded deal metrics" above) What requires the query-analytics tool instead: - "How many deals were in stage X on December 1st?" (requires historical snapshots) - "What was the pipeline value entering Q4?" (requires reconstructing past state) - "Which deals changed stage last month?" (requires stage transition history) For historical or point-in-time questions like these (past field values, stage transitions, change history, trends over time), use the query-analytics tool. It queries the analytics event log of every CRM change and can reconstruct past state.

ActionTry it

Read-context

Read Clarify product knowledge and documentation. Use this whenever you need to understand a feature, apply best practices, or reference detailed technical documentation. Available contexts: - field-docs: Field type guidelines, AI prompt writing instructions, available relationship data, and examples for creating or updating custom fields - calendar-docs: Guide for the calendar tools: timezone handling, timed vs all-day events, resolving participants by name via CRM lookup, participant rules (always include the user), choosing which connected account a create lands on (other writes follow the event owner), update-replaces-not-merges semantics, recurrence (RRULE), notify_participants defaults, and event-ID conventions. - campaign-docs: Complete guide to email campaigns including structure, variables, enrollment, exit conditions, and best practices - artifact-docs: The required data rules for authoring an artifact: which store its SQL runs on, how to prove a query, and how to size a result for the frame, plus what makes a dashboard, QBR, or sales document effective. Read before you write SQL for a dashboard, report, or artifact.

ActionTry it

Respond-to-calendar-event

RSVP to a calendar event you've been invited to. ## When to use this tool Use to accept, decline, or tentatively accept a meeting invite. The user must be a participant on the event. ## Important notes - The RSVP sends as soon as this tool is called, with no confirmation step. Confirm with the user first. - Status values: `yes` (accept), `no` (decline), `maybe` (tentative).

ActionTry it

Run-workflow

Start a workflow run. Three modes are supported, picked by which input is set: 1. **Replay**: pass `workflow_run_id`: copies the trigger state of a completed run and re-executes the action blocks against the current workflow definition. 2. **Run on a record**: pass `record` ({ entity, id }): for workflows whose trigger is an entity CDC event (e.g. on-create-person). The record is fetched and shaped into a synthetic trigger event so downstream blocks see the same `{{trigger.state.event.object.*}}` surface as a real run. 3. **Run from a raw event**: pass `trigger_event`: a JSON object passed through to the manual-run service. For webhook-triggered workflows, the value is wrapped as `{ type: 'clarify-webhook:automation-webhook', data: { body, headers: {} } }` automatically. Exactly one of `workflow_run_id`, `record`, or `trigger_event` must be set. ## When to use this tool - The user is iterating on a workflow and wants to test it against a specific record, a sample payload, or a previously completed run - After editing a workflow with create-or-update-workflow to fix a failing run, immediately call this tool to verify the fix ## How it works - For modes 2 and 3, the workflow must be published (`published_at` not null) and of type `workflow`; sequences and system workflows have their own paths - For mode 1, the source run must be in a terminal state (success, failed, cancelled) - This produces real side effects (e.g. sends emails, updates records); there is no sandbox - Waits up to 30 seconds for the new run to finish; emits an artifact so the user can open the run page either way ## Returns - The new run ID and its terminal status (success / failed / cancelled), waiting up to 30 seconds - On failure: the failing block's recent logs are included inline; read them before deciding the next edit - For longer-running workflows, returns the still-active status with a hint to poll get-workflow-runs ## Important notes - Limit fix-and-rerun loops to 5 attempts on the same workflow in one conversation. After 5 unsuccessful runs, stop and post the latest failure log to the user; further guessing is unlikely to help - To find an existing run ID for the replay mode, call get-workflow-runs first

ActionTry it

Send-email

Send an email immediately through the user's connected email account. ## When to use this tool Use this tool when the user wants to send an email right now, without reviewing or editing it first. For example: "send John an email about the meeting" or "email the team about the deadline". If the user wants to review, edit, or compose an email before sending, use the create-email-draft tool instead. ## How it works 1. The email is sent through the user's connected Gmail or Outlook account 2. The email sends as soon as this tool is called, with no confirmation step. Confirm with the user first 3. On success the email appears in their Sent folder ## Threading When the user asks you to reply to an existing email or thread, populate replyToMessageId so the sent message threads correctly in Gmail/Outlook. Look up the value from the message_id field on a Message record (use get-records or query-data on the Message entity to find the inbound message you are replying to). Omit replyToMessageId when sending a brand new email that is not part of an existing thread. ## Body format The body is plain text that will be converted to rich text. Use these conventions: - Lines starting with "- " become bullet list items - Lines starting with "1. ", "2. ", etc. become numbered list items - Blank lines become paragraph breaks (consecutive blank lines are collapsed) - Lines starting with "# " or "## " become bold section labels - **bold** spans become bold text - Markdown links [label](https://example.com) and bare URLs become clickable links - Never put a placeholder or fabricated link in the body (e.g. [Recording] *(link)*, [link], or a guessed URL). Include a link only if you have the real URL from a tool result; if a referenced resource has no link available, leave it out and say in your reply that it needs to be added - Markdown horizontal rules like "---" are treated as section dividers and are not shown - Everything else becomes a regular paragraph Follow the system prompt and any activated email-writing skill for the user's preferred voice, tone, structure, and formatting judgment. If a skill gives more specific email-writing guidance that conflicts with these generic style defaults, prefer the skill. If no more specific guidance applies, write like a normal email, not a dashboard or report. Keep bodies compact and easy to scan: - Prefer short paragraphs and a small number of bullets - Use section labels only when they materially improve scanability - Use at most one blank line between sections or paragraphs - Keep section labels attached to their content; do not add a blank line immediately after a section label - Keep lists attached to the sentence or item they explain; do not add a blank line immediately before a bullet or numbered list - Avoid em dashes and dash-heavy constructions; use commas, colons, parentheses, or separate sentences instead - Do not use decorative separators, tables, code fences, blockquotes, or report-style spacing ## Recipients Recipient addresses (to/cc/bcc) must come from a CRM person record. Look them up with get-records or by querying the person entity before sending; never guess an address from a name and company domain. Any recipient that matches no person record is rejected unless you pass it in confirmedNewRecipients (only for addresses the user supplied for a brand-new contact). ## Important notes - The user must have a connected email account (Gmail or Outlook via Nylas) - On success, tell the user the email was sent and to whom. If the tool reports an error, tell the user it could not be sent and why; never claim an email was sent when the tool returned an error

ActionTry it

Submit-feedback

Submit concise feature requests or feedback about MCP tools. Use when: - Missing functionality prevents completing a user's request - User requests a new feature or reports a limitation - User suggests improvements Keep feedback brief and focused on what's needed or what could be improved.

ActionTry it

Update-campaign

Update an existing email campaign: rename it, change its target list, adjust the sender, edit its email steps, or configure send time windows. To create a new campaign, use the create-campaign tool instead. Requires a `campaign_id` (get it from the `get-campaigns` tool). 📖 **For comprehensive campaign rules and examples**: Use `read-context` with context: "campaign-docs" ## When to use this tool - When the user asks to rename a campaign, change its target list, adjust the sender, or edit email steps (subjects, bodies, timing) - When the user asks to configure when a campaign's emails can be sent (send time windows) ## Editing email steps Each email_step must specify an operation: - **update**: Modify an existing step (requires step_number, optional: subject, body) - **remove**: Delete a step (requires step_number) - **insert**: Add a new step (requires step_number, subject, body, delay_after_days) To append at the end, use step_number equal to the current number of steps + 1. A **update** with `body` **replaces the entire body of that step**. To add to or tweak part of an existing email, first read the current body with `get-campaigns`, apply your change to it, and send the full updated body. Never regenerate a body from scratch for a targeted edit, or you will discard the user's existing content. If you cannot make a requested change without replacing content the user wrote, confirm with them first. Campaign email bodies support text and images. Tables, videos, and other embedded content are not supported and are silently dropped. Do not attempt to insert those; tell the user they aren't supported yet. An image must be an <img src="..."> pointing at an already-hosted public URL. A plain link to a file (for example a PDF URL) is fine. ## Essential rules 1. **Email/Delay Pattern**: Emails and delays MUST alternate. When inserting an email, always specify `delay_after_days` 2. **First email**: `delay_after_days: 0`. Subsequent emails: minimum 1 day 3. **Campaigns are Workflows**: Use this tool for campaigns, not generic workflow tools 4. Campaign emails are templates sent to multiple people 5. Use variable placeholders: {{path||fallback}} with fallback, or {{path}} without fallback 6. Only use the variables listed below (do not invent variables) 7. Campaigns that have already been sent cannot be modified ## Threading: send_as_reply Set `send_as_reply: true` on a step to thread it as a reply under the previous step (uses the prior message id and "Re: <previous subject>"). Use it when a follow-up bumps the prior email and the recipient should see it inline: - **Use** `send_as_reply: true` for content like "circling back", "just bumping this", "wanted to follow up", or any step that explicitly references the prior email's call to action without a new pitch. - **Leave it off (default)** when the step introduces a new angle, case study, or call to action; recipients triage by subject, and a fresh subject signals fresh content. Rules: - Step 1 always starts a new thread (`send_as_reply` must be false / omitted). - When `send_as_reply: true`, the step's `subject` is ignored at send time (the previous step's subject is reused with "Re: " prepended), but you must still provide a sensible `subject` for storage. Fallback rules (fallbacks make emails feel natural when data is missing): - ALWAYS include fallbacks for human-identifiable information: → Person names (first_name, last_name, full_name) → use "there", "Friend", etc. → Company/organization names → use "your company", "your organization", "your team" → Job titles and roles → use "your role", etc. → Location/city names → use "your area", "your region", etc. - Skip fallbacks ONLY for technical/structured data: → URLs, email addresses, phone numbers → Dates, timestamps, IDs → Numerical values and metrics ## Examples <example> Update a campaign step: { "campaign_id": "abc-123-def", "email_steps": [ { "operation": "update", "step_number": 2, "subject": "Quick follow-up, {{person.name.first_name||there}}" } ] } </example> <example> Insert a new step: { "campaign_id": "abc-123-def", "email_steps": [ { "operation": "insert", "step_number": 2, "subject": "Quick check-in", "body": "<p>Hi {{person.name.first_name||there}},</p><p>Just wanted to follow up...</p>", "delay_after_days": 3 } ] } </example> ## Send Time Windows Use `send_windows` to control when campaign emails can be sent. Emails scheduled outside the configured windows are held until the next open window in the creator's timezone. - Pass `null` to disable windows (emails can be sent at any time). - Pass a `{ windows: { <day>: { start, end } | null } }` object to set windows. Days map to lowercase names: `sunday`, `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`. A `null` value for a day means no sending that day. `start` and `end` are 24-hour `"HH:mm"` strings with `start < end` (same-day windows only). - Omit `send_windows` entirely to leave existing windows unchanged. <example> Set business-hours-only send windows: { "campaign_id": "abc-123-def", "send_windows": { "windows": { "sunday": null, "monday": { "start": "09:00", "end": "17:00" }, "tuesday": { "start": "09:00", "end": "17:00" }, "wednesday": { "start": "09:00", "end": "17:00" }, "thursday": { "start": "09:00", "end": "17:00" }, "friday": { "start": "09:00", "end": "17:00" }, "saturday": null } } } </example> ## Important notes - delay_after_days: 0 = immediate, 3 = day 3, 7 = day 7 - Minimum 1 day enforced between consecutive emails automatically - Body should be formatted as HTML. Put each paragraph in its own <p> tag; paragraphs render with a blank line between them. Use <br/> only for a hard line break within a paragraph (e.g. between signature lines) - When describing timing to users, say "immediately" for delay_after_days = 0, "on day 3" for delay_after_days = 3 - Use get-campaigns tool to find campaign IDs for updates

ActionTry it

How the Clarify MCP integration works

The Clarify MCP integration connects your Dench AI CRM directly to Clarify MCP, so agents can read and act on your Clarify MCP data as part of everyday work — answering questions in chat, keeping your CRM in sync, and running automations without anyone copying data between tools.

46 actions are available for agents to invoke on your behalf. Every call runs through Clarify MCP's own authorization, scoped to the account you connect.

Set up Clarify MCP in Dench

  1. 1

    Sign in to your Dench workspace and open Integrations.

  2. 2

    Find Clarify MCP and click Connect — you'll authorize access through Clarify MCP's own sign-in flow. No API keys or code required.

  3. 3

    Ask an agent to use Clarify MCP in chat, or call it from an automation.

  4. 4

    Manage or disconnect the connection any time from workspace settings.

Frequently asked questions

How does the Clarify MCP integration work with Dench?

The Dench Clarify MCP integration connects your AI CRM to Clarify MCP, so AI agents can work with your Clarify MCP data as part of chats, automations, and CRM workflows. You connect your account once, and every agent in your workspace can use it — governed by your workspace permissions.

What actions can AI agents perform with Clarify MCP via Dench?

The Clarify MCP integration currently exposes 46 actions, including Add-comment, Create-campaign, Create-email-draft, Create-or-update-agent, Create-or-update-artifact, and Create-or-update-calendar-event. Agents invoke them on your behalf from chat or from automations.

Do I need to write code to connect Clarify MCP to Dench?

No. You connect Clarify MCP from your Dench workspace using Clarify MCP's own sign-in and authorization flow — no API keys to copy, no glue code to maintain.

Is the Clarify MCP integration secure?

Connections are authorized through Clarify MCP's own authentication flow, and Dench stores only the authorization needed to act on your behalf. You can review and disconnect the Clarify MCP connection from your workspace settings at any time.

Clarify MCP | Dench AI CRM