← Back to all apps

Clarify

Productivityby Clarify
Launched Apr 24, 2026 on ChatGPT

Connect Clarify and work with your CRM using natural language. Retrieve, create, and update records, analyze your pipeline, and generate insightsβ€”all through conversation.

Query deals, companies, people, and meetings without switching apps. Find and import new leads that match your ICP directly from the conversation. Create tasks, lists, and outbound sequences automatically.

Build and configure Clarify Agents from Claude to automate your entire GTM motion.

Your Clarify data is retrieved in real-time, and permissions are respected.

Stop clicking through interfaces. Just ask what you need.

40ChatGPT Tools
26Claude Tools
ClarifyDeveloper
ProductivityCategory

Use Cases

productivity

Available Tools

Add comment

add-comment
Full Description

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.

Parameters (3 required)
Required
entitystring

The type of entity to add the comment to

Options:companydealpersonmeetingtask
idstring

The ID of the record to add the comment to (e.g., deal ID, company ID)

messagestring

The comment text in markdown format. Supports **bold**, *italic*, lists, headings, and links.

Create campaign

create-campaign
Full Description

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
Parameters (2 required, 3 optional)
Required
campaign_namestring

Campaign name.

email_stepsarray

Email steps with subject, body, and delay_after_days, in send order.

Optional
descriptionstring

Campaign description

from_emailstring

Email address of the inbox to send this campaign from: one of the user's own connected accounts, or an inbox another member delegated to them. get-campaigns lists the inboxes they can send from. Defaults to their primary account when omitted.

list_idstring

Target list ID. Get from get-lists tool. Optional for drafts.

Create email draft

create-email-draft
Full Description

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).

Parameters (3 required, 7 optional)
Required
bodystring

Email body content. Plain text by default (Follow the system prompt and any activated email-writing skill for the user's preferred voice, tone, structure, and formatting judgment; if no more specific guidance applies, write like a normal compact email, not a report: avoid em dashes and decorative separators, use at most one blank line between sections or paragraphs, keep section labels attached to their content, and keep lists attached to the sentence or item they explain. Lines starting with "- " become bullet items, "1. " etc. become numbered items, "# " or "## " become bold section labels, blank lines become paragraph breaks, **bold** becomes bold text, markdown links [label](url) or bare URLs become clickable links, and horizontal rules like "---" are not shown.); when isHtml is true, pass a complete, self-contained HTML document with inline styles instead.

subjectstring

Email subject line

toarray

Email addresses to send to

Optional
bccarray

Email addresses to BCC

ccarray

Email addresses to CC

confirmedNewRecipientsarray

Recipient addresses that match no CRM person record but that the user supplied or confirmed verbatim (e.g. a brand-new contact). Only set after the user gives you the address; never echo an address you generated yourself here to bypass the recipient check.

fromstring

Address to draft from. This also picks which connected account the email is drafted through: it may be any of the user's connected accounts' primary address or one of that account's configured aliases. Only set it when the user explicitly asks to draft from a specific account or alias (e.g. "draft from sam@hackathonradar.com" or "draft from my work account"); otherwise omit to use the default account's primary address. Must be a connected account's address or a configured alias, or the draft is rejected.

isHtmlboolean

Set to true when body is a complete hand-authored HTML email (e.g. a branded, table-based layout). The HTML is stored on the draft verbatim. Omit or set false for the default plain-text body that gets converted to rich text.

replacesDraftIdstring

Nylas draft ID of a previous draft in this conversation that this call is revising. When set, the existing Gmail draft is updated in place instead of creating a duplicate. Read the value from the "Nylas draft ID:" line of the prior create-email-draft tool result. Omit when starting a fresh draft (different recipient, different thread, or any non-revision).

replyToMessageIdstring

Short Nylas message_id of the inbound email being replied to (e.g. 19df41adc5abe23b). Set this when drafting a reply on an existing thread so the draft appears threaded 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). Do not pass a Clarify record _id or a Nylas draft id here.

Create list

create-list
Full Description

Create a new list (saved view) based on a SQL query. Use this after running a SQL query to save the results as a reusable list that the user can access later. The SQL query should already be validated and working.

When to use this tool

Use this after running a SQL query to save the results as a reusable list that the user can access later.

Important notes

  • SQL queries must use explicit column names (e.g., "SELECT name, email FROM person")
  • SELECT * queries are not supported and will fail validation
  • Column names must be table-qualified (e.g., "person.name" not just "name")
  • The tool validates both the main query and count query to ensure the list will work when opened
  • Aggregate functions in ORDER BY or HAVING must also be in the SELECT clause

After creating a list: 1. Tell the user what was created in a friendly, conversational way 2. Provide a clickable markdown link to open the list (the tool response will include this) 3. Encourage the user to click the link to view the list 4. Keep your response concise - the tool will provide the formatted link

Parameters (3 required, 2 optional)
Required
entitystring

The type of record to create list for

Options:companypersondealusertask
sqlstring

The SQL query that defines the list

titlestring

The title/name of the list

Optional
descriptionstring

Short description explaining what this list contains or its purpose

emoji

Emoji character to display with the list

Create or update agent

create-or-update-agent
Full Description

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 (e.g., all person creates, all deal updates), confirm the user actually wants that broad scope. Each agent run uses resources, so reducing unnecessary runs matters. 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.

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.

Parameters (0 required, 11 optional)
Optional
agentIdstring

The ID of an existing agent to update. Omit to create a new agent. When you are an agent updating your own configuration, set this to your own agent ID.

descriptionstring

Short description of the agent (up to 300 characters). Required when creating a new agent.

enabledboolean

Whether the agent is enabled. Only applicable when updating an existing agent. New agents are created enabled.

instructionsstring

The instructions that define how the agent behaves. Markdown up to 30,000 characters. Required when creating a new agent. To reference a skill, embed a markdown link `[Skill Name](/skills/<skill id>?ref=skill)` using the skill ID from the available-skills list; this renders as a skill pill and links the skill structurally, whereas plain bold text does not. Do not hardcode or guess a Clarify record URL in the instructions. To link to a specific record, copy its exact `Link:` value from tool output; for records that vary per run, instruct the agent to use the `Link:` its own tools return rather than writing out a URL template.

mcp_serversobject

MCP connector configuration keyed by server ID. Each server maps tool names to permissions. Only listed servers are available. This is a full replacement; always include the complete set of servers and tools, not just additions. Do not pass this field when updating if you want to keep the existing MCP servers unchanged. Format: { "<server-id>": { "tool-name": "always_allow"|"needs_approval" } }

modelstring

Model tier the agent runs on. "fast" is a faster, meaningfully cheaper model β€” use it for simple, narrow, deterministic work (classification, field extraction, tagging/routing, short templated summaries) and high-volume triggers where per-run cost matters. "smart" (the default) is more capable β€” use it for multi-step reasoning, tool orchestration, judgment, or quality-sensitive writing like emails and summaries. Decide the tier yourself from the task; do not make the user choose. When updating, omit to leave the current tier unchanged.

Options:smartfastauto
namestring

Agent name (1-64 characters). Required when creating a new agent.

throttleobject

Caps how many times this agent may run per hour, e.g. `{ "limit": 5 }`. Throttles are hourly only. Omit to leave the current cap unchanged. Pass `null` to reset the limit back to the default of 100 runs/hour.

toolsobject

Map of tool name to permission. Only listed tools are available to the agent. This is a full replacement; always include the complete set of tools, not just additions. Do not pass this field when updating if you want to keep the existing tools unchanged. Format: { "query-data": "always_allow", "create-or-update-records": "needs_approval" }

triggersarray

Triggers that activate the agent. Each trigger specifies an event the agent responds to. An agent can have multiple triggers.

visibilitystring

Controls agent run visibility. "public" lets anyone in the workspace see the agent's runs (chats). "private" restricts runs to the creator only. The agent itself is always visible to everyone. Defaults to "private".

Options:publicprivate

Create or update artifact

create-or-update-artifact
Full Description

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, deck, analysis, breakdown, summary, overview, or plan, even when they never say the word "artifact". Reach for one especially for a multi-part deliverable, or for current-state / snapshot data. Artifacts open at their own URL and the user revisits them, so produce one instead of dumping the whole thing into the chat transcript. A deck, slides, a QBR, a pitch, or a readout is a slide deck built with @clarify/ui/deck (see "Building a deck").

  • If the deliverable is really just a browsable set of records of a single

object type (columns are direct fields, no aggregation or charts), a Clarify list via create-or-update-list fits better than an artifact. When the user asked for a report or analysis but a list would serve them, offer the list and explain the distinction first; if they explicitly asked for a list, just build it.

Referring to it in chat

When you mention the result to the user, name it by what it is β€” a dashboard, report, deck, sales document, or plan. Do not call it an "artifact" unless the user used that word first.

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 every definition you have not saved, and ask the user whether to save it. If the user does not answer, offer again in each reply until the user answers. 4. Call get-artifact-component-doc once. Pass every @clarify/ui/* component this version uses in one specifiers list. Call it every time, even for a component you used before in this conversation, or one you already know. Docs can change between calls, within one conversation or overnight. Do not trust memory from earlier in this conversation for current props. The one-line summaries below help you pick a component. They do not give its props.

Never guess a stage name, a date field, or a metric definition. Never put a query in an artifact that you have not run. Never use a component's props from memory or from its one-line summary. Always call get-artifact-component-doc first. No exceptions.

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.

Before you save an artifact version, check the source you are about to send. Fix every clear defect in that same version without asking. This holds for source the user pasted too, unless they said to keep it exactly as given. Leave working code alone. Add no feature the user did not ask for. In this check, a missing date picker, header, or period comparison is not a defect. The authoring rules still apply to source you write yourself. Tell the user in one plain sentence what you fixed, with no build details, e.g. "I also fixed two queries and removed unused code." The "Self-check" in the "artifact-docs" context lists the defects: dead code, broken math, manual work a built-in covers, missing contract, slow queries. Read it with read-context if you have not in this conversation.

Set type to "Dashboard" when the content you authored is an interactive dashboard, "Deck" when it is a slide deck, and "Document" when it is a page to read; leave it unset for other kinds of library item.

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).
  • Pad the root so the report never sits flush to the frame: give the top-level

element ArtifactContents returns an outer padding class (p-6). Never return an unpadded root. Drop the padding only on a section you deliberately want edge-to-edge (a hero image, a full-width map). A slide deck does NOT get this padding: build it with @clarify/ui/deck (see "Building a deck"), which fills the frame with h-screen.

  • Import what it uses from the provided modules: react (v19.2.8) (hooks),

recharts (v3.8.1) (charts ONLY), framer-motion (v12.40.0) (animation β€” import m, not motion; the scaffold already wraps the mount in LazyMotion), and the Clarify design-system components below (@clarify/ui/* β€” chrome, layout, controls). JSX is supported β€” do not add a build step or import anything else.

  • Prefer the design-system components for structure and text (cards, buttons,

the @clarify/ui/text typography set); reach for bespoke components and/or standard HTML elements only for what they don't cover.

  • Render ALL text through the @clarify/ui/text components (Heading1 to

Heading4, Text, CaptionText), not raw <p>/<span>/<h*> and not type utilities (text-lg, font-semibold), so type stays on the design scale.

  • Write a Text nested inside another Text as

<Text as="span" size="inherit" weight="font-medium"> so it stays inline at the parent's size; a nested Text without as renders a div and breaks the sentence. Every URL, email address, and phone number is a LinkText with an href (mailto: for an email, tel: for a phone number), e.g. Email <LinkText size="inherit" href="mailto:phil@acme.org">phil@acme.org</LinkText> for access. A bare or bold email, URL, or phone number is a bug: the viewer cannot click it.

  • Use only standard Tailwind utilities. The theme ships the standard layout,

spacing, and sizing utilities plus the semantic color utilities in Styling below. Avoid arbitrary-value classes (p-[37px]), because only standard utilities are guaranteed to be emitted. Chart colors come from useArtifactTheme() instead (see Charts below).

  • Emoji are allowed, but prefer a Clarify icon (@clarify/ui/icon) whenever

one fits. Never mix the two in one context: a single list, row of cards, or slide uses icons throughout or emoji throughout, never two emoji and one icon.

  • NOT emit <!doctype>, <html>, <head>, <body>, a root/mount node, a

<script> tag, or any createRoot(...) call β€” the scaffold provides all of that, and emitting them breaks the artifact.

Styling

Color comes only from these semantic utilities. Each one follows dark mode and the workspace palette, so one class renders right in both modes:

  • text-fg-1, text-fg-2, text-fg-3, text-fg-4, text-fg-onbrand, text-fg-on-inverse
  • bg-canvas, bg-surface, bg-subtle, bg-muted, bg-inverse
  • border-border, border-border-subtle, border-border-strong
  • text-danger, text-warning, text-success, text-info, bg-danger, bg-warning, bg-success, bg-info, border-danger, border-warning, border-success, border-info
  • bg-danger/10, bg-warning/10, bg-success/10, bg-info/10
  • bg-brand-subtle, text-brand-subtle-fg
  • text-accent-1, text-accent-2, text-accent-3, text-accent-4, text-accent-5, text-accent-6, bg-accent-1, bg-accent-2, bg-accent-3, bg-accent-4, bg-accent-5, bg-accent-6, border-accent-1, border-accent-2, border-accent-3, border-accent-4, border-accent-5, border-accent-6, from-accent-1, from-accent-2, from-accent-3, from-accent-4, from-accent-5, from-accent-6, via-accent-1, via-accent-2, via-accent-3, via-accent-4, via-accent-5, via-accent-6, to-accent-1, to-accent-2, to-accent-3, to-accent-4, to-accent-5, to-accent-6
  • bg-accent-1/10, bg-accent-2/10, bg-accent-3/10, bg-accent-4/10, bg-accent-5/10, bg-accent-6/10
  • Set a background only through a surface token (bg-canvas, bg-surface,

bg-subtle, bg-muted, bg-inverse) or a tint (bg-brand-subtle, bg-accent-1/10, bg-danger/10). Use @clarify/ui/card for a raised surface before you reach for bg-surface. On bg-inverse, color text with text-fg-on-inverse, which flips with the surface. text-fg-onbrand is a fixed white for an accent fill, so it vanishes on the dark-mode inverse.

  • Never use a palette shade (text-gray-600, bg-blue-50) or a hex value,

and never a CSS variable in an inline style for color. A shade ignores dark mode and the workspace palette. When the user asks for a specific color, use the nearest accent or status utility.

  • Never restyle the internals of a design-system component, such as a Block's

border or a Card's padding. Wrap the component or pick a different one.

  • Give each surface one accent moment at most: one text-accent-1 figure,

one highlighted series, or one call to action.

  • Keep a gradient rare. Build it from accent stops only

(bg-gradient-to-br from-accent-1 to-accent-3) and count it as the surface's one accent moment.

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). Standard button attributes (onClick, disabled, type) work as usual. To link somewhere, use a real <a href> (or RecordLink for a record), not a button.
  • @clarify/ui/card β€” A themed surface for grouping content into panels β€” the default frame for a section, chart, or stat. Compose the parts; CardTitle renders a heading, CardDescription muted subtext.
  • @clarify/ui/text β€” The themed typography set. Use these for ALL text instead of raw <p>/<span>/<h*> or Tailwind text utilities, so weight, size, and color stay on the design scale. DisplayText and Heading1 to Heading4 are headings (largest to smallest); CaptionText is small muted; LinkText is an anchor. Write a Text nested inside another Text as <Text as="span" size="inherit" weight="font-medium"> so it stays inline; a nested Text without as renders a div and breaks the sentence. A nested LinkText takes size="inherit". Every URL, email address, and phone number is a LinkText with an href (mailto: for an email, tel: for a phone number). A bare or bold email, URL, or phone number is a bug: the viewer cannot click it.
  • @clarify/ui/avatar β€” A circular avatar for a person or company. Compose AvatarImage with an AvatarFallback (initials shown while the image loads or is missing). Set size via className (e.g. size-8).
  • @clarify/ui/badge β€” A small status/label pill β€” deal stage, a count, a state.
  • @clarify/ui/icon β€” Clarify's icon set, imported by name as <Name>ClarifyIcon. Reach for one wherever a label earns a glyph: a section heading, a status row, a list bullet, a stat caption. Clarify's own nouns have their own icons, so prefer the specific name over a generic one: an autonomous agent is AgentClarifyIcon, not PeopleClarifyIcon. Every icon paints with currentColor, so color comes from a text-* utility on the icon or its parent (text-success, text-warning, text-danger, text-fg-3, text-accent-1) and size from the size prop, 16 by default. Emoji are still fine, but prefer an icon when one fits, and never mix icons and emoji in the same list, card row, or slide. The full set, each used as <Name>ClarifyIcon: Activity, AddAbove, AddBelow, Agent, Agent2, AIEmail, AINote, AIPlay, AITask, Alarm, AlignLeft, AlignRight, AngelList, ArrowDown, ArrowDownLeft, ArrowDownRight, ArrowLeft, ArrowLeftRight, ArrowRight, ArrowTrendDown, ArrowTrendUp, ArrowUp, ArrowUpDown, ArrowUpLeft, ArrowUpRight, Artifact, ArtifactDashboardSparkle, ArtifactDeckSparkle, ArtifactDocumentSparkle, Attach, BankNote, Bell, BellCheck, BellOff, BillingPlans, Binoculars, BoardView, Bolt, Book, Bookmark, Box, Braces, Briefcase, Calendar, CalendarCross, Campaign, Car, CaretDown, CaretLeft, CaretRight, CaretUp, Changelog, ChatDocked, ChatFloating, Check, Checkbox, CheckCircle, CheckCircleFilled, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, ChromeExtension, Circle, CircleDashed, CircleFilled, ClarifyAI, Cleanup, Clock, Collapse, Columns, Comment, Company, Component, Connected, Connectors, CreditCard, Credits, CursorClick, Customize, Database, DateNumbers, Deal, Delete, Deny, DesktopApp, DocumentUpload, Dot, Download, DragHandles, Duplicate, DynamicClock, Edit, EditOff, Email, EmailForward, EmailReply, EmailReplyAll, Enter, EnumFilled, EnumOutline, ErrorCircle, Expand, Eye, EyeClosed, Facebook, FileText, Filter, Flag, FlaskConical, Folder, Globe, GraduationCap, Granola, Graph, GroupView, Hand, Handshake, Hash, Heart, Home, Image, ImportToList, Inbox, InfoCircle, Instagram, Integrations, Intercom, InviteMembers, Json, Key, Keyboard, LastRun, Layers, Link, LinkedIn, ListView, Location, Lock, Logout, MapPin, Megaphone, Members, Mention, Menu, Minus, Mobile, Monitor, More, MultiSelect, NewChat, Note, NoteBook, Number, Objects, Package, Pause, People, Phone, Pin, Plus, Popout, PriorityHigh, PriorityLow, PriorityMedium, PriorityUrgent, Puzzle, Question, QuestionCircle, Receipt, Record, Refresh, RepFace, Rocket, RTEBold, RTECode, RTEH1, RTEH2, RTEH3, RTEItalic, RTElistCheck, RTElistOrdered, RTElistUnordered, RTEParagraph, RTEQuote, RTESmiley, RTEStrikethrough, RTEUnderline, Search, SendEmail, Settings, SettingsAlt, Shield, ShoppingCart, SidebarLeft, SidebarLeftOpen, SidebarRight, SidebarRightOpen, Skill, Slack, SocialAngellist, SocialFacebook, SocialInstagram, SocialLinkedIn, SocialX, Sort, SortAsc, SortDes, Sparkle, Spinner, Stage, StageOfDeal, StarFilled, StarOutline, StatusBlocked, StatusCanceled, StatusDone, StatusFailed, StatusInProgress, StatusPaused, StatusScheduled, StatusTodo, Stop, Store, Summary, Tag, TagRemove, Target, Text, ThumbsDown, ThumbsUp, Ticket, ToggleLeft, ToggleRight, Trophy, Truck, Typography, Undo, Unread, Upload, Users, Variable, VerificationFail, VerificationSuccess, Video, VideoBack10s, VideoCC, VideoForward10s, VideoFullScreen, VideoOff, VideoPause, VideoPIPExpand, VideoPIPMinimize, VideoPlay, VideoPlayFilled, VideoVolumeFull, VideoVolumeMute, View, Warning, Webhook, Workflow, Wrench, X, XSocial, Zap.
  • @clarify/ui/switch-icon β€” The default way to show a boolean in a report: a static on/off status icon (green when on, gray when off) for an active flag, an enabled setting, a yes/no cell. It shows state driven by checked; it is not an interactive control. Reach for @clarify/ui/switch only when the user must flip the value.
  • @clarify/ui/switch β€” An interactive on/off toggle (Radix). Use it only when the report needs a real control the user flips to change what they see; drive it with checked + onCheckedChange and your own state. To show a boolean as read-only status, use @clarify/ui/switch-icon instead.
  • @clarify/ui/tabs β€” A sectioned content switcher. Each TabsTrigger and its TabsContent share a value; drive it with defaultValue (uncontrolled) or value + onValueChange.
  • @clarify/ui/clarify-avatar β€” Clarify's branded avatar: shows the image, else colored initials, shaped by the object type (round for people, squared for companies/deals).
  • @clarify/ui/name-chip β€” A compact name + avatar chip for a person or company (a deal owner, a contact). Falls back to colored initials when there is no image.
  • @clarify/ui/dashboard-header β€” The header at the top of a dashboard β€” title and an optional scope line (period, owner, filters applied). The "Last updated" freshness label and the refresh-all button are added automatically from the dashboard's own queries; do not pass them. Use exactly one per dashboard; for a section title inside the report use BlockHeader instead.
  • @clarify/ui/filter-bar β€” A row that lays out filter and selector controls under the header, for reports meant to be re-sliced by the viewer. Skip it on a fixed view; it is only layout β€” put the controls inside it.
  • @clarify/ui/date-range-picker β€” A dashboard's time control, placed in the FilterBar. Only add it when the artifact has ClickHouse (event) queries the range can scope β€” timeRange applies to ClickHouse only, so a Postgres-only (current-state) artifact must not include one; it would scope nothing. It opens on the last 30 days by default, so keep that default unless the report needs another window. To change it, defaultQuickSelect must be one of the built-in presets exactly β€” "Today", "Yesterday", "Last 7 days", "Last 30 days", "Last 90 days", "Month to date", "Year to date", or a quarter label like "Q3 2026" β€” an unrecognized label (e.g. "Last 6 months") is ignored and falls back to the 30-day default; for any other span pass defaultDateRange instead. Hold its onApply({ from, to }) range in state and pass it to each ClickHouse section as useQuery(sql, { timeRange: range }) β€” the backend scopes the query to it, so the picker label always matches what the queries run. Its Clear button sets the picker to "all time": onApply then fires with from/to undefined, so useQuery runs over all data β€” never pass a partial range. onApply also gives previous (the equal-length window just before the range, present only for a preset) for period-over-period comparison. Dates after today are disabled by default; set canSelectFutureDates only for a forward-looking report (pipeline or forecast scoped by a future close date). Never fetch all rows and slice client-side, and never interpolate the dates into your SQL.
  • @clarify/ui/filter β€” A dropdown filter for the FilterBar β€” lets the viewer narrow the dashboard by a value (owner, stage, region). Single-select by default (value is a string, onChange fires undefined when cleared); pass multiple for multi-select (value is a string[]). Hold the selection in state and pass it to each section's useQuery so the backend scopes the query; never fetch all rows and slice client-side.
  • @clarify/ui/block β€” The card every metric, chart, or table sits in β€” never render one bare. Give it the section title with title (plus optional info for a definition and description), and pass the section's query (a useQuery result). The Block renders that query's loading skeleton, error, empty, and refresh states, then reveals your content once the rows arrive β€” so never hand-write isLoading/error/empty branches. Build the content from query.rows. Every query-driven block MUST also pass underlyingQuery={{ sql, source?, title?, columns }} β€” the detail records behind the aggregate β€” so the block offers a "See underlying data" drilldown (a block with a query but no underlyingQuery is incomplete). Author the detail SQL as the individual records (no GROUP BY), match its source to the block's query, and author columns with the right headers, formats (currency/percent/count/date), alignment, and any render β€” leave columns out only when you can't determine them, and the table falls back to auto-deriving number/date/text from the query's column types. Make every row's name link to its record by projecting entity_id and entity_type next to the record's name (aliased name) β€” on Postgres deal._id AS entity_id, 'deal' AS entity_type, deal.name AS name (use the record's own entity in place of deal); on ClickHouse entity_id, entity_type, and argMax(JSONExtractString(properties,'name'), timestamp) AS name. The framework reads name, entity_id, and entity_type literally off each row, so any other alias (deal_id, record_id, a bare id) silently renders plain text β€” no link, no error, and nothing to self-heal from. Get the three names right the first time. Keep a name column in columns β€” it renders the record as a clickable link β€” but leave entity_id and entity_type out of columns; they only carry the link target and never render.
  • @clarify/ui/block-header β€” A block's header β€” a title, an optional description, an optional info tooltip for a definition, and an action row. Prefer passing title/info/description straight to Block (it renders this header for you); reach for BlockHeader directly only for a header outside a Block. Keep it to title, description, and one info affordance.
  • @clarify/ui/section-divider β€” A light separator, optionally labeled, between clusters of blocks in a long artifact. Prefer whitespace first; reach for this only when a section break needs to be explicit.
  • @clarify/ui/use-query β€” The only way to fetch live workspace data β€” a React hook with loading, refreshing, and error state, a refresh action, and auto-refetch when the SQL or timeRange changes. Give each data section its own useQuery so one slow or failed query never blanks the others. Pass a time window as useQuery(sql, { timeRange }) to scope the query server-side; never interpolate dates into the SQL. Give every query a title β€” the same string you pass to that Block β€” surfaced in server logs so a failing query is identifiable without decoding its SQL. Show a period-over-period comparison by default on time-scoped sections: add compareToPrevious β€” pass range.previous ?? true (true runs the same SQL for the equal-length window right before timeRange; the DateRangePicker onApply payload's previous is calendar-precise) β€” and read the prior period from previousRows (same shape as rows). It is a no-op without a timeRange.
  • @clarify/ui/artifact-theme β€” The theme for charts. Call useArtifactTheme() and spread its charts values onto stock Recharts props so colors, axis, legend, and tooltip match the report and re-theme with light/dark. Never hardcode chart colors. Color each <XAxis>/<YAxis> with stroke={charts.axis.line}, else Recharts draws its #666 default. Pick the color group by the decision tree (stop at first match): positive/negative state β†’ charts.positive/charts.negative/charts.neutral; ordinal dimension (stage, priority, tier, size/amount band) β†’ charts.sequential[i] light-to-dark ordered by the band sequence (not the measure), even for a single series; more than one series in one space β†’ charts.categorical[i] (six colors, wrap past six with categorical[i % 6]; charts.other for null/unknown only); otherwise a single charts.primary. Give pie/donut slices a thin charts.pieStroke border (half-opacity, blends light/dark). The legend key is a circle in our font (charts.legend). Order the query rows by the dimension: rank by the measure descending, else the dimension's own order (stage, priority), alphabetical only as a last resort. Recharts <Legend> re-sorts keys alphabetically unless you pass itemSorter={null}, so ALWAYS set itemSorter={null} on <Legend> to keep it in the row order and matching the tooltip. Size the chart on the chart itself with responsive width="100%" height={320}. A fixed pixel height is required and a parent div's height does not flow into the chart. Give the Y axis headroom so the tallest value never touches the top. On bar/line/area set domain={[0, (max) => Math.ceil(max * 1.1)]} on the <YAxis>.
  • @clarify/ui/value-text β€” Formats a single value for display β€” number, currency, percent, count, duration, or date. Reach for it for every numeric or date value so formatting stays consistent; it inherits the surrounding text size and color. Percent takes a fraction (0.12 β†’ 12%); duration takes milliseconds; set compact to abbreviate large numbers ($2,480,000 β†’ "$2.48M").
  • @clarify/ui/trend-indicator β€” A change indicator: a direction arrow, the change value, and a comparison label. You rarely build this by hand β€” on a KPI, add a comparison to MetricBlock (comparison={{ field, label }}) and it renders this for you from the query. The comparison is free: useQuery(sql, { compareToPrevious }) re-runs the SAME query for the prior period, so you never write a second query or diff values yourself β€” no reason to skip a trend on a time-scoped tile. Construct TrendIndicator directly only for a bespoke comparison whose two sides you already hold. direction sets the arrow; sentiment sets the color and defaults from direction β€” set sentiment to invert when up is bad (churn, cost).
  • @clarify/ui/metric-block β€” A single KPI tile: a small title (with an optional definition tooltip), a large value, and under it an optional period-over-period trend and/or a muted caption. Self-contained (its own card), so drop it straight into a KPI strip, never wrap it in a Block. Group them in a 3-across grid and wrap to more rows as the count grows. Set compact to abbreviate large headline numbers ($2.48M, 47K). Give each tile its OWN query (a useQuery result) whose SQL returns exactly one row and the single value shown, and read it as query.rows[0]?.<col>. Do the aggregation in SQL (count/sum/avg/…, or a ratio via countIf(…) / count()) so a zero result shows as the value (0) rather than an empty state. Never pass a multi-row query and derive the value in JS (rows.reduce(…), summing or dividing across rows, or plucking one row out of many). Show a period-over-period trend by default whenever the tile is time-scoped: run the query with compareToPrevious and pass comparison={{ field: "<col>", label: "vs previous period" }}. field names the same column value reads, and the tile pulls the prior value and window straight from the query and computes the change itself (never compute a delta in JSX). Add isInverted: true for up-is-bad metrics (churn, cost). It shows a % for a nonzero prior, the raw figure for a prior of 0 (0 β†’ 10 reads "+10"), or "Same as …" when unchanged; a missing/non-numeric prior or value shows no trend. description is a separate muted caption. Whenever the tile has a query, it MUST also pass underlyingQuery={{ sql, source?, title?, columns }}, the detail records behind the metric, so the tile offers a "See underlying data" drilldown (a query-backed tile with no underlyingQuery is incomplete); author it as the individual records (no GROUP BY) and give underlyingQuery.columns the right headers and formats, leaving them out only when the right columns are unknown. Make every row's name link to its record by projecting entity_id and entity_type next to the record's name (aliased name). On Postgres deal._id AS entity_id, 'deal' AS entity_type, deal.name AS name (use the record's own entity in place of deal); on ClickHouse entity_id, entity_type, and argMax(JSONExtractString(properties,'name'), timestamp) AS name. The framework reads name, entity_id, and entity_type literally off each row, so any other alias (deal_id, record_id, a bare id) silently renders plain text: no link, no error, and nothing to self-heal from. Get the three names right the first time. Keep a name column in columns, which renders the record as a clickable link, but leave entity_id and entity_type out of columns; they only carry the link target and never render. A static tile with no query skips it. MetricValue is the same value, trend, and caption with no card and no title, for a headline number on a slide inside SlideData: pass size="headline" there and let the slide carry the metric name.
  • @clarify/ui/data-table β€” A sortable table of rows β€” reach for it whenever a block shows tabular data. Presentational: it renders already-loaded rows, so wrap it in a Block that owns the loading / empty / error chrome and pass rows={query.rows} and truncated={query.truncated}. For columns, either pass columnTypes={query.columns} to auto-derive them (numbers right-align, dates format, headers show even on 0 rows) and omit columns; or pass explicit columns β€” { key, header?, format?, align?, render? } β€” when you need currency/percent/count formatting or a custom cell (auto-derive only knows number/date). key reads and sorts the cell; header defaults to key; format is one of number | currency | percent | count | duration | date; align="right" suits numbers; render: (row) => … draws a badge/chip/link (sorting still follows key). For sticky total / subtotal rows, pass footerRows β€” an array of { cells: { [columnKey]: cell } }, one entry per row, top-to-bottom; each cell is EITHER { aggregate: "sum" | "avg" | "min" | "max" | "count" } (computed over the shown rows and formatted with that column's format; count renders as an integer; on a date column only count applies, the others are dropped) OR { content: <node> } for a label ("Total") or a precomputed value. An aggregate totals only the shown rows, so a sum under-counts when truncated. Every row always renders; by default ~10 show before the table scrolls β€” usually omit visibleRows and set it only to change how many are visible before scrolling, never to the total row count (that caps nothing and falls back to the default ~10). Do aggregation and column selection in SQL β€” never trim wide rows in the component. A name column links to its record automatically when the rows carry entity_id and entity_type β€” the SAME contract as underlyingQuery, so project those two next to the name (d._id AS entity_id, 'deal' AS entity_type on Postgres; entity_id and entity_type on ClickHouse) in any table query you want linked, not just drilldowns. All three names are read literally, so a different alias (deal_id) silently renders plain text. Leave entity_id/entity_type out of columns β€” auto-derive hides them once a row links, and listing them explicitly renders raw ids as their own columns. A column render overrides the link.
  • @clarify/ui/record-link β€” A record's name as a clickable chip that opens its record page. A DataTable already links its name column on its own, so reach for this only outside that column: a second record column in the same table (via that column's render), or a non-table layout like a card grid or a list. objectName is the entity (deal, company, person), recordId its id; take both from the row's entity_type / entity_id. It degrades instead of breaking: no recordId renders a non-clickable chip, no objectName renders plain text. A plain <a href> navigates too: an in-app path opens in the same tab, an external URL in a new tab. But prefer RecordLink for a record's name so you get the chip styling and this safe degradation, rather than hand-building a record URL.
  • @clarify/ui/chart-tooltip β€” The Recharts <Tooltip> wearing Clarify's tooltip frame (an inverted rounded card). It IS the Recharts Tooltip, so render it directly inside a Recharts chart (<ChartTooltip />) and never pass it to another element's content prop. Nesting one inside a Tooltip's content recurses without end. With no content it renders Clarify's legend body: per series a color dot, the neutral name, and the right-aligned value. formatter and labelFormatter still work. Pass your own content to override the body.
  • @clarify/ui/deck β€” A slide deck, for anything the user will present: a deck, slides, a QBR, a pitch, a readout. Deck owns navigation, the thumbnail rail, the overview grid, present mode, and the slide transition; each Slide is a bare 960Γ—540 design-px canvas (scaled to fit, clipped, never scrolling) with only children, notes, and className, so the author composes the whole slide with the text components, the semantic color utilities, and flex, grid, or absolute positioning. The slide sets its own type scale, so the text components render at slide size: CaptionText 14px, Text 16px, Heading2 24px, Heading1 32px, DisplayText 44px, and size="text-4xl" 56px, text-5xl 72px, text-6xl 86px for a cover title or a headline number. SlideData takes the slide's query (and underlyingQuery, same contract as Block) and renders the loading, error, empty, and refresh states around a chart, a DataTable, or a MetricValue with size="headline", with no card. Steps / Step reveal a slide one beat at a time. useSlidePageNumber() returns { current, total }. Slides must be direct children of Deck (fragments are fine; a component that renders slides is not). The authoring rules (every useQuery hoisted above the Deck, one idea per slide, semantic colors only, className="h-screen") are in the "Building a deck" section of the artifact tool.
  • @clarify/ui/document β€” A long-form page, for anything the user will read: a business case, a prospecting brief, a proposal, a memo, an account plan. Document owns the title block (title, description, meta badges), the page width and text scale, the paragraph measure and leading, and a table of contents that follows the reader and jumps to any section. Each Section has a title and children; a Section inside a Section is a subsection, and its heading level and table-of-contents depth come from the nesting. Callout (tone: note, info, warning, success, danger) is a tinted aside. Body copy is Text as="p", a list is a plain ul or ol with Text as="li" items, and a side-by-side row is a grid, as the usage shows. A document about workspace data is live like a dashboard: every figure renders from query.rows through a MetricBlock, MetricValue, chart, or DataTable in a Block inside a Section, and prose interpolates figures from the rows. The authoring rules (every useQuery hoisted above the Document, every heading a Section, className="h-screen") are in the "Building a document" section of the artifact tool.

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). Merging two query results by a stable key to add columns to one table (see "Keep queries simple") is a join by key, not this banned across-rows aggregation.

  • Show a period-over-period trend by default. "Is this up or down from

before?" is the first question a reader asks of any KPI or trend, so a comparison is the highest-value thing most sections carry β€” add one to every time-scoped MetricBlock and single-measure line chart unless a prior period is genuinely not meaningful (see the skip list). This costs you nothing extra: do NOT write a second query and do NOT diff values in JS. Add compareToPrevious={range.previous ?? true} to the section's EXISTING useQuery (the DateRangePicker's calendar-precise prior window, else the equal-length shift) β€” the host re-runs that same SQL for the prior period and returns it as previousRows (same shape as rows). Never hesitate for lack of a comparison query; the flag *is* the comparison query.

  • On a MetricBlock, pass comparison={{ field: "<col>", label: "vs previous period" }}

where field is the same column value reads; the tile pulls the prior value and window from the query and computes the change itself β€” never compute a delta in JSX. Add isInverted: true when up is bad (churn, cost).

  • On a line chart, plot the prior window as a second series (see "Comparing

two periods on a chart" under Charts).

  • Skip it only where there is no meaningful prior period: a non-time snapshot

(no timeRange), a Postgres current-state read, a pure breakdown or distribution (share by stage / owner / category), or a cumulative running-total measure. It is a no-op without a timeRange.

  • The result carries { columns, rows, rowCount, truncated }. columns is

{ name, type }[] (type is one of number | string | boolean | date | datetime), present even for a 0-row result β€” hand it to a DataTable's columnTypes to auto-align/format and keep headers on an empty result. When truncated is true the row cap was hit β€” tell the user the data was limited. error is the typed failure (with a code); refresh() re-runs the query.

  • Choose the store per query with useQuery(sql, { source }). The default

(omit it) is ClickHouse, the analytics event log β€” use it for history, trends, point-in-time, and stage transitions. Pass { source: 'postgres' } only when the question is about current record state (the values as they are now, a plain SELECT), which the event log can't answer as directly. The SQL dialect differs per source (see the schema guidance below), so pick the source first, then write for it.

  • Pass a static SQL string literal so the query stays predictable and

validatable β€” never build SQL from runtime values, including dates. To scope a query to a time window, pass the range to useQuery (below); the backend applies it β€” never interpolate from/to into the SQL.

  • To scope a section to a time window (e.g. a DateRangePicker's range), pass it

as the second argument: useQuery(sql, { timeRange: { from, to } }). The backend applies it as a table-level row filter on analytics.event, so every read of that table β€” nested subqueries, CTEs, and each UNION branch β€” is filtered independently and the SQL stays static; never add your own timestamp bounds for the picker's window. useQuery re-runs on its own when the SQL or range changes, so the report re-slices on Apply with no extra wiring. timeRange applies to the ClickHouse source only β€” a Postgres query ignores it (see below for its dates).

  • Scope every ClickHouse query to the artifact's window with { timeRange }.

The default source is the event log, so a metric read over all history is almost never what the reader wants β€” pass { timeRange } to every ClickHouse useQuery, and give the artifact a date control (a @clarify/ui/date-range-picker) to drive it. Add the picker **only when the artifact has ClickHouse queries**, and never for a named period. A Postgres-only artifact gets no picker: it is current record state, with date WHERE filters relative to today (now(), CURRENT_DATE, date_trunc). A named period of any tense ("July", "Q4") is never Postgres-only, since current state filtered to it empties once it ends. Read it from the event log with typed dates: a fixed timeRange for activity, and for state an argMax with timestamp < '<day after end>', which tracks the period while it runs and freezes at its end. The picker defaults to the last 30 days, so queries stay fast; the viewer can Clear it to all time. With no picker, a rolling ask ("this month") builds its timeRange from new Date(). Ask when the user could mean either. State the window in the UI.

  • **Give every useQuery a title β€” the same string you pass to that

section's Block.** So a section wrapped in <Block title="Deals by stage"> fetches with useQuery(sql, { title: 'Deals by stage' }). The title is forwarded to the server logs so a failing query is identifiable without decoding its SQL; it has no effect on the result.

  • **A detail (drilldown) query must project name, entity_id, and

entity_type.** A block's underlyingQuery.sql returns individual records, and the framework reads those three column names literally off each row to build the record link. On Postgres neither entity_id nor entity_type exists as a column, so alias them yourself β€” d._id AS entity_id, 'deal' AS entity_type, d.name AS name; on ClickHouse project entity_id and entity_type. Any other alias (d._id AS deal_id) silently renders plain text with no link and no error, so there is no failure to self-heal from β€” get the three names right while you write the SQL.

Minimal example
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { useQuery } from '@clarify/ui/use-query';
import { useArtifactTheme } from '@clarify/ui/artifact-theme';
import { Block } from '@clarify/ui/block';

export function ArtifactContents() {
  const { charts } = useArtifactTheme();
  const query = useQuery(
    "SELECT entity_type, count() AS events FROM analytics.event WHERE workspace_slug = '<your workspace>' GROUP BY entity_type ORDER BY events DESC",
    { title: 'Events by type' },
  );

  // No isLoading/error/empty branches β€” the Block renders those from `query`
  // and shows the chart once `query.rows` is ready.
  return (
    <div className="p-6">
      <Block title="Events by type" query={query}>
        <BarChart responsive width="100%" height={320} data={query.rows}>
          <CartesianGrid stroke={charts.grid.stroke} vertical={false} />
          <XAxis dataKey="entity_type" stroke={charts.axis.line} tick={{ fill: charts.axis.tick, fontSize: charts.axis.fontSize }} />
          <YAxis domain={[0, (max) => Math.ceil(max * 1.1)]} stroke={charts.axis.line} tick={{ fill: charts.axis.tick, fontSize: charts.axis.fontSize }} />
          <Bar dataKey="events" fill={charts.primary} />
        </BarChart>
      </Block>
    </div>
  );
}

Charts

Charts are Recharts (imported from recharts), styled by the artifact theme so every report's charts read as one system. Four rules:

  • **Size the chart with responsive + width="100%" + a fixed pixel height,

set on the chart itself** β€” e.g. <BarChart responsive width="100%" height={320}>. responsive keeps the width fluid; the height must be an explicit number because a chart has no intrinsic height. Put these on the chart, not a parent <div> β€” Recharts draws to the chart's own box, so a parent's height does not flow in and a bare <BarChart responsive> collapses to nothing. Do not use ResponsiveContainer.

  • **Give the Y axis headroom β€” the tallest value must never touch the top of

the frame.** Recharts otherwise caps the domain at the data's max, so the biggest bar or point runs into the top edge. On bar/line/area, set domain={[0, (max) => Math.ceil(max * 1.1)]} on the <YAxis>: keep the baseline at 0 and round the top up past the tallest value. Pie/donut have no axis, so this does not apply.

  • Take every color and label style from useArtifactTheme() β€” never a

hardcoded hex, a raw var(--color-*), or an opacity/tint variant. const { charts } = useArtifactTheme() gives the color groups below plus charts.axis, charts.grid, charts.legend, charts.tooltip, and charts.cursor; spread each onto the matching Recharts prop (fill, stroke, tick, …). Put stroke={charts.axis.line} on every <XAxis>/<YAxis> β€” without it Recharts paints its #666 default axis line. Color must encode something β€” if it doesn't, don't spend it; most charts are single-series and monochrome. Pick the group by this decision tree, top to bottom, stop at the first match:

1. Positive/negative or good/bad state (won vs lost, on-target vs at-risk, period delta) β†’ the semantic set: charts.positive, charts.negative, charts.neutral (excluded/no-value). If a chart uses semantic colors, every series in it is semantic β€” never mix semantic and categorical. 2. Ordinal dimension (stage, priority, tier, score/size band, an age or amount bucket like "51-250" or "1K-5K") β†’ charts.sequential[i], light to dark. This holds even for a single series β€” a lone bar or line over an ordinal dimension is sequential, NOT primary. Order the axis by the dimension's own sequence (1-10 β†’ 100K+, P0 β†’ S4), never by the measure, so lightness climbs monotonically down the axis; spread across the ramp rather than clustering (e.g. steps 0, 2, 3, 5). Steps 0–2 are fills only β€” never lines or points. Never put a categorical color on an ordinal dimension. 3. More than one series in the same space (stacked/grouped bar, multi-line, stacked area, pie, donut, treemap) β†’ charts.categorical[i] in order. Six distinct colors; beyond six categories, wrap with charts.categorical[i % 6] so colors repeat rather than run out. charts.other is reserved for "Unknown"/"No value"/excluded categories only β€” never a real category past the sixth β€” and always sits last in stack and legend. 4. Everything else β†’ a single charts.primary. When in doubt, this.

Assign deterministically so the same data yields the same colors on every render and a category keeps its color across a dashboard. For categorical and primary, sort rows by the charted measure descending, then assign in token order. For a sequential/ordinal chart, order rows by the dimension's natural sequence instead (rule 2), not the measure, so the ramp reads in order. This row order sets the legend order too β€” the legend follows row/series order. The precedence in general: order by rank (the charted measure, descending); if there is no measure to rank by, keep the data's natural order; fall back to alphabetical only as a last resort, when neither exists β€” never sort categories alphabetically by default. When a chart plots more than one series, give each a name and add <Legend iconType={charts.legend.iconType} wrapperStyle={{ color: charts.legend.text, fontSize: charts.legend.fontSize }} />. State the rule you applied when explaining a generated chart ("single series, so primary"). See the @clarify/ui/artifact-theme entry above for the full pattern.

  • Give pie/donut slices a blended border. Spread

stroke={charts.pieStroke} strokeWidth={1} onto each <Cell> β€” a half-opacity separator that reads in both light and dark. Never set a solid white stroke; it looks wrong on a dark background.

  • **Render the tooltip as <ChartTooltip /> (from @clarify/ui/chart-tooltip)

directly inside the chart** β€” it IS the Recharts <Tooltip>, already framed. Never pass it to another element's content (e.g. content={<ChartTooltip/>}): ChartTooltip is the Tooltip, so nesting one inside a Tooltip's content recurses without end and crashes the report.

  • Don't bucket a metric by time unless the user asked for that grouping.

Reach for a time GROUP BY (toDate(timestamp), toStartOfWeek/Month(…)) ONLY when the request names the granularity β€” "by month", "per week", "grouped by day", "weekly", "monthly". "Over time", "trend", or "as a line chart" is NOT that request: a time series already IS a trend, so it can't be the trigger. By default show a single aggregate over the report's window β€” "deals created", not "deals created per month" β€” and let the report's date filter (timeRange, above) set the period. Grouping by a non-time category (stage, owner) is unaffected.

  • Compare two periods on a line chart by default. A single-measure line

chart over the window should almost always show the prior period too, so fetch with compareToPrevious and draw it as a second series: pair query.rows[i] with query.previousRows[i] into one data array ({ bucket, current, previous }) and plot two <Line>s. Color them charts.categorical[0] / charts.categorical[1] (never hardcode); a dashed previous line (strokeDasharray) reads as the baseline. This index pairing is chart data shaping, not the banned in-JSX aggregation. Skip the overlay only for a multi-series chart (a breakdown already uses the series slots) or a non-time category chart.

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

  • Use actor.entity and actor.source_id to tell an automated actor (an

agent or an API key) from a human user, and group or exclude by them when an automation may skew a metric

  • m_stage (String) β€” Materialized JSONExtractString(properties,'stage'); empty if absent
  • m_amount (Float64) β€” Materialized JSONExtractFloat(properties,'amount'); 0 if absent
  • m_close_date (Nullable(Date)) β€” Materialized toDateOrNull(properties.close_date);

NULL if absent/unset. A native date β€” range-filter and group it directly (m_close_date >= '2026-01-01', toStartOfMonth(m_close_date)) with no toDate wrapper

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:access-request-created, clarify:access-request-decided, 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.

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.

-- Deals that ENTERED a closed stage inside the window.
WITH closed AS (
SELECT entity_id, timestamp, m_stage AS stage_at_event
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
  AND type = 'clarify:update'
  AND arrayExists(d -> JSONExtractString(d, 'path', 1) = 'stage'
                    AND JSONExtractString(d, 'val') IN ('Won', 'Lost'),
                  JSONExtractArrayRaw(diff))
UNION ALL
-- diff is empty on create: catches deals imported directly into a closed stage
SELECT entity_id, timestamp, m_stage
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
  AND type = 'clarify:create' AND m_stage IN ('Won', 'Lost')
)
SELECT countIf(final_stage = 'Won') AS deals_won,
     count() AS deals_closed,
     countIf(final_stage = 'Won') / nullIf(count(), 0) AS win_rate
FROM (SELECT entity_id, argMax(stage_at_event, timestamp) AS final_stage
    FROM closed GROUP BY entity_id)

The outer argMax collapses each deal to its last transition in the window, so a deal that goes Won β†’ Lost inside it counts once, as Lost. There is no delete guard here on purpose β€” a deal won in the window was won even if the record was later deleted; to drop since-deleted deals, reconstruct current state (above) and keep only the entity_ids that are still live. Matching on val works because stage is not sensitive; a sensitive field's val reads 'REDACTED' (its path is kept), so for those match on path presence alone.

For a "closed per month" trend, bucket by the event timestamp β€” that is when the transition actually happened. One data limitation to surface, not hide: on a batch-imported workspace every clarify:create lands on the import date, so deals that closed before they were imported collapse onto that date β€” the log has no real historical close date for them. m_close_date does not fill that gap: it is the *expected* close date (often in the future), right for a pipeline-by-expected-close forecast but wrong as a "when did we win" bucket.

When you do bucket a retrospective metric by a date field that can run into the future (m_close_date, any custom date field), cap the buckets to the current period β€” AND m_close_date <= today(), or HAVING bucket <= toStartOfMonth(today()). A future bucket holds only open pipeline, so a "what happened" metric (win rate, deals won) reads there as losses or zeros. Drop the cap only for an intentional forward-looking pipeline/forecast.

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.

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.

  • Summed or counted returned rows by hand β€” you totaled values in your

reasoning instead of in the query. Push it into the query (sum(...), count(), avg(...), countIf(...)) over the reconstructed CTE, and read the total from the result.

  • Pasted a literal id list into IN ('…','…') β€” you transcribed ids from a

prior result. Filter with a subquery instead: entity_id IN (SELECT <fk> FROM <cte>) (the join pattern above).

  • JSONExtractFloat64 does not exist β€” use JSONExtractFloat.

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).

A table is not pinned to one query. To add a column to a table already in the artifact, edit that table β€” never add a second table beside it. Update the existing artifact with artifact_id and edits. First choice: widen that table's query β€” project the new column, or JOIN it in when it lives in the same source (see the cross-entity JOIN guidance above) β€” and add the matching DataTable column. Fallback, only when one query cannot return the column (a different source, e.g. a Postgres current-state table plus a ClickHouse stage-history metric, which cannot JOIN): run a second useQuery, build a lookup keyed by entity_id from its rows, map the first query's rows onto it to attach the column, and pass the combined array to the one DataTable.

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:

const range = /* the DateRangePicker's applied { from, to, previous } */;
const won = useQuery(dealsWonSql, {
  timeRange: range,
  title: 'Deals won',
  compareToPrevious: range.previous ?? true,
});
// winRate is the same shape with format="percent"; pipelineCreated with format="currency"
return (
  <div className="grid grid-cols-3 gap-4 p-6">
    <MetricBlock
      title="Deals won"
      query={won}
      value={won.rows[0]?.deals_won}
      format="number"
      comparison={{ field: 'deals_won', label: 'vs previous period' }}
      // Detail SQL must project name, entity_id, entity_type β€” read literally
      // off each row; any other alias silently drops the link (see Block docs)
      underlyingQuery={{ sql: dealsWonDetailSql }}
    />
    {/* win rate, pipeline created β€” same shape */}
  </div>
);

Building a deck

A deck is the artifact for anything the user will present or walk someone through: they ask for a deck, slides, a QBR, a pitch, or a board or team readout. A dashboard is for watching numbers move against a window; a report is for reading. When the ask is "present", build it with @clarify/ui/deck (Deck, Slide, SlideData, Steps, Step) and fetch its props with get-artifact-component-doc like any other component.

  • **Hoist every useQuery to the top of ArtifactContents and pass the

results into the slides. Hard rule.** Only the on-screen slide is mounted, so a useQuery inside a slide refetches and shows a skeleton on every visit.

  • The canvas. Each Slide is an empty 960Γ—540 design-px canvas, scaled

to fit the viewer. It clips and never scrolls, so anything past its edges is cut off. You compose the whole slide: the inset, the alignment, the type, the color, the hierarchy. Design each slide like a designer would, not like a document: one idea, one focal point, generous margins (p-12 to p-16), and few words.

  • Type on a slide. The slide sets its own type scale, so the text

components render at slide size with no size classes: CaptionText for an eyebrow (14px, add uppercase tracking-wider), Text for body (16px), Heading2 for a subtitle (24px), Heading1 for a slide title (32px), DisplayText for a big title (44px). Three display sizes exist only on slides, through the size prop: text-4xl (56px) for a cover title, text-5xl (72px), and text-6xl (86px) for a headline number, where you wrap the MetricValue in <Text as="div" size="text-6xl">. Never write a raw text-size utility on a slide; pick the component or the size.

  • Color on a slide. Use the semantic utilities and nothing else:

text-fg-1 to text-fg-4, bg-surface, bg-subtle, bg-muted, border-border, the status tones (text-danger, bg-success/10), and the six accents (text-accent-1, bg-accent-1/10, border-accent-1, gradients bg-gradient-to-br from-accent-1 to-accent-2). A dark cover or section slide uses bg-inverse with text-fg-on-inverse, which flips with the surface. Never pair it with text-fg-onbrand, a fixed white that vanishes on the dark-mode inverse. Never a palette shade (gray-700, blue-500) or a hex: those ignore dark mode and the workspace palette. Backgrounds are allowed on slides. One accent moment per slide is plenty.

  • Layout on a slide. Flex, grid, and absolute positioning are all allowed

inside a slide (absolute inset-0, bottom-12 left-16), with the standard spacing and sizing scale. Arbitrary values (p-[37px]) are not emitted, so stay on the scale. Never add a scroll container.

  • Shape. 6 to 12 slides. Open on a cover, divide chapters with a section

slide, end on a closing. One idea per slide. Vary the layout across slides: alternate full-bleed, split, and metric-focal compositions so neighboring slides do not share a template. A deck where every slide is the same layout reads as filler. When content does not fit, split the slide; never shrink type to cram it in.

  • Data on a slide. Wrap a query-driven body in one SlideData (it takes

the slide's query and underlyingQuery, same contract as Block) so the loading, error, empty, and refresh states render without a card. Inside it, build from query.rows: a MetricValue with size="headline" for a big number, a chart with height around 260 to 320, or a DataTable, which scrolls inside the slide, so return every row the story needs. A Block or MetricBlock is also fine when a card is what you want.

  • A deck shows live data, with no controls. Every query runs again each

time the deck opens, so never call it a snapshot and never write "as of" with a date you typed. State the window on the cover, computed: a quarter or a named period as text, a relative window from new Date(). Pass a fixed timeRange to each ClickHouse useQuery and add no DateRangePicker or filter bar.

  • Every number in the deck comes from the rows. A figure anywhere on a

slide or in notes is interpolated from query.rows, never typed from a query you ran while authoring. A typed number is stale the next time the deck opens.

  • useSlidePageNumber() returns { current, total }. Show the page

number on every slide after the cover, placed consistently (a corner is usual). Slides must be direct children of Deck (fragments are fine; a component that renders slides is not).

  • Steps / Step when the order of ideas is the point: a slide that lists

next steps, priorities, plays, or asks puts each one in a Step so it lands on its own click. Each Step must be a direct child of Steps.

  • notes carry what the presenter says on that slide, not a summary of it.
  • Give the Deck a title and className="h-screen" so it fills the

frame.

  • Set type to "Deck" so the library shows it as a deck.
  • Self-review before you save: every useQuery sits above the Deck;

every slide has one idea and nothing near its edges; every slide after the cover shows its page number and neighboring slides do not share a layout; type goes through the text components and color through the semantic utilities, with no palette shade or hex anywhere; the deck opens on a cover and ends on a closing; the window is stated; no typed number or date anywhere; "snapshot" appears nowhere.

Building a document

A document is the artifact for anything the user will read rather than watch or present: a business case, a prospecting brief, a proposal, a memo, an account plan, a one-pager. A dashboard is for watching numbers move; a deck is for presenting. When the ask is "write", build it with @clarify/ui/document (Document, Section, Callout) and fetch its props with get-artifact-component-doc like any other component.

  • **A document about the workspace's data uses live components, the way a

dashboard does. Hard rule.** Hoist every useQuery to the top of ArtifactContents and pass the results into the sections. A section that makes a claim about numbers renders them from query.rows through a MetricBlock, a MetricValue, a chart, or a DataTable inside a Block, and a Callout or paragraph interpolates its figures from the rows, never types them. Static prose is for framing, recommendations, and narrative; anything the reader would want current comes from a query. A document about the user's pipeline, deals, accounts, or activity with no useQuery is the wrong build.

  • Shape. One Document with a title, a one- or two-sentence

description, and meta badges (the date computed from new Date(), the audience, a status). Then three to eight top-level Sections, each with a plain title; nest a Section inside another for a subsection. The table of contents builds itself from the sections, so every heading is a Section, never a bare heading. Two levels of nesting is the usual limit; three is the most the sidebar lists.

  • Prose is Text from @clarify/ui/text with as="p". The document

sets its measure and leading. Three to five sentences each, one idea per paragraph. Emphasis inside a paragraph is a nested Text with as="span", size="inherit", and weight="font-medium", so it stays inline. An email address, URL, or phone number in prose is a LinkText from @clarify/ui/text with href (mailto: for email, tel: for a phone number), never plain or bold text. A list is a ul or ol with list-disc or list-decimal, pl-5, and flex flex-col gap-2, each item a Text as="li". A nested list sits inside an item with mt-2 and switches to list-[circle], or list-[lower-alpha] for an ol. Never write a raw <p> or heading tag.

  • Asides. Callout with a tone: info for a recommendation,

warning for a risk, success for a decision or milestone, danger for a blocker, note (the default) for context. At most one or two per section. A customer's or stakeholder's words go in a blockquote with border-l-2 border-border-strong pl-7 py-2, holding a Text at size="text-lg" and a Text as="cite" at size="text-sm" with variant="secondary" and not-italic.

  • Laying out data. Each query-driven figure sits in a Block or

MetricBlock inside the Section that makes the claim. Two to four blocks side by side use grid grid-cols-2 gap-6 (or grid-cols-3 gap-5, grid-cols-4 gap-4) with each child in a min-w-0 div; prose stays single column. A document is read at about 880px, so a chart's height stays at or under 320. A table of non-data content (a plan, an option comparison) uses DataTable from @clarify/ui/data-table with a plain rows array.

  • Live dates. Every query runs again each time the document opens, so

never write "as of" with a date you typed and never call the data a snapshot. A figure typed from a query you ran while authoring is stale the next time the document opens. State the window in the description or a meta badge, computed.

  • Give the Document className="h-screen" so it fills the frame and

the sidebar can scroll with it.

  • Set type to "Document" so the library shows it as a document.
  • Self-review before you save: every useQuery sits above the

Document; each claim about numbers renders from query.rows; every heading is a Section; every paragraph is a Text; the window is stated; no typed number or date anywhere; "snapshot" appears nowhere.

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.

Parameters (0 required, 8 optional)
Optional
artifact_idstring

The ID of an existing artifact to update. Omit to create a new artifact.

contentstring

The full JSX source of the new version β€” a React component named `ArtifactContents`. Provide this to create an artifact, or to fully replace an existing version. To patch an existing artifact, send `edits` instead. See the tool description for the authoring contract and how to fetch live data with `useQuery`.

descriptionstring

A short description of the artifact.

editsarray

Targeted edits applied to an existing artifact's saved source, sent instead of the full `content`. Requires `artifact_id`; read the current source with `get-artifact-source` first. Preferred for updating a large artifact β€” send only the changed regions rather than re-emitting the whole file.

namestring

The name of the artifact. Required when creating a new artifact.

reasonstring

Why this version was created.

summarystring

A short human-readable summary of what changed in this version.

typestring

The category of this library item. Set to "Dashboard" when the content is an interactive dashboard, "Deck" when it is a slide deck built with @clarify/ui/deck, "Document" when it is a long-form page built with @clarify/ui/document. Leave unset for other kinds.

Options:DashboardDeckDocument

Create or update calendar event

create-or-update-calendar-event
Full Description

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.

When not to use this tool

  • This writes to the user's external calendar (Google / Microsoft) and can

email invites. If the user means a custom object (one of the entity options on create-or-update-records, e.g. an "Events" object), create a record there instead.

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.
Parameters (0 required, 10 optional)
Optional
account_emailstring

Which of the user's connected calendar accounts to create the event on. Only relevant when the user has more than one calendar connected; omit to use their default account. Ignored on update β€” an update always targets the account that owns the event.

descriptionstring

Event description. Maximum 8000 characters.

event_idstring

Existing Nylas event ID (from get-calendar-events) to update. Omit to create a new event.

locationstring

Event location, maximum 255 characters. Omit to leave unchanged on update; passing an empty string clears the field.

notify_participantsboolean

Whether to send invite/update emails. Defaults to true on create, false on update.

participantsarray

Email addresses to invite or update.

participants_modestring

How to apply `participants` on update. 'add' (default) merges the listed emails into the existing attendee list, preserving anyone you didn't mention. 'replace' substitutes the entire attendee list with the listed emails; anyone not listed is removed and (if notify_participants is true) gets a cancellation. Use 'replace' only when the user explicitly asks to remove attendees.

Options:addreplace
recurrencearray

Optional RRULE strings (RFC 5545) to make the event recurring, e.g. ["RRULE:FREQ=WEEKLY;BYDAY=MO"]. Update and delete operations apply to the whole series.

titlestring

Required when creating; optional when updating. Maximum 1024 characters.

whenobject

Required when creating. When updating, provide only if rescheduling.

Create or update custom object

create-or-update-custom-object
Full Description

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.

Parameters (0 required, 6 optional)
Optional
backgroundColorstring

Optional avatar background color (a Tailwind color name). Applies on both create and update.

Options:redorangeamberyellowlimegreenemeraldtealcyanskyblueindigovioletpurplefuchsiapinkroseneutral
descriptionstring

AI context description for this object type. Required when creating. Pass null to remove on update.

entitystring

The entity identifier of an existing custom object to update (e.g., "c_sales_order"). Omit to create a new custom object.

iconstring

Optional avatar icon name. Must be one of the curated avatar icon names. Applies on both create and update.

Options:BoxPackageFileTextFolderTicketBriefcaseStoreComponentLayersMapPinGlobeBankNoteCreditCardReceiptShoppingCartCarTruckBookGraduationCapShieldKeyMegaphoneFlagTargetTrophyRocketZapPhoneImageTagWrenchHeartUsersHandshakeDatabaseFlaskConical
namestring

The display name. Required when creating. The name will be normalized to a c_ prefixed identifier (e.g., "Sales Order" becomes "c_sales_order").

pluralstring

The plural label. Required when creating.

Create or update custom fields

create-or-update-fields
Full Description

Create new fields or update existing field metadata (name, options, AI config) on any entity (company, person, deal, meeting, task, 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.
Parameters (2 required)
Required
entitystring

Entity identifier (e.g., "person", "company", "deal", "c_my_object")

fieldsarray

Array of field definitions to create or update. Batch all fields for the same entity into one call.

Create or update list

create-or-update-list
Full Description

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
  • When creating a new list, always set an emoji: a single emoji that represents the list's theme (e.g. "πŸ’°" for a deals list, "🎯" for priority accounts, "πŸ“…" for a meetings list)

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.
  • When updating an existing list's SQL, do not rebuild its membership from memory or a session summary. A large unexplained drop in matched records usually means the new query is incomplete (e.g. after a context reset); re-read the list's current definition and reconcile against it instead of treating the new SQL as authoritative.

Column choice for list views

Lists render one row per root record, so every SELECT column must belong to the root entity. Read the list-docs context (via read-context) for the column rules: the title alias, showing a to-one relationship (join <root>$<fk_column> and select its primary field aliased <root>$<fk_column>:__object__, never the bare *_id), and why many-to-many columns don't work in a list.

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

Parameters (1 required, 5 optional)
Required
entitystring

The type of record the list is for

Optional
descriptionstring

Short description explaining what this list contains or its purpose

emoji

A single emoji shown with the list. When creating a new list, always set one that reflects its theme. Pass null to clear it on update.

list_idstring

The ID of an existing list to update. Omit to create a new list.

sqlstring

The SQL query that defines the list. Required when creating a new list.

titlestring

The title/name of the list. Required when creating a new list.

Create or update records

create-or-update-records
Full Description

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".

Messages are link-only

Messages (email and LinkedIn) are synced from the provider β€” you cannot create a message or edit its fields here. You can only correct who a message is linked to. Pass the message id with a people relationship (link or unlink) and no attributes, for example to attach a person to a message that was ingested without the right contact. The relationship field is people and its targets are person records.

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.

Parameters (2 required, 1 optional)
Required
entitystring

The type of records to create or update. All records must be the same entity type.

recordsarray

Array of records to create or update. Each contains attributes and optional relationships. Include id to update, omit id to create. All records in one call must be the same operation (all creates or all updates).

Optional
match_onstring

Enables upsert mode for a batch of creates. Names a unique field to match on (marked unique in the schema, e.g. "domains", "email_addresses"; non-unique, relationship, and object fields are rejected). For each record, if exactly one existing record already has the same value for this field, the incoming attributes are merged into that existing record and its ID is returned instead of failing with a duplicate error; records with no match are created. A value that matches multiple existing records errors. Only valid when every record is a create (no `id`). Scalar unique fields are matched exactly; collection fields are matched case-insensitively; append/remove/set on a collection field is honored against the matched record, not just a plain replace.

Create record

create-record
Full Description

Create a record in Clarify.

Parameters (2 required, 1 optional)
Required
attributesobject

Non-relationship field values for the record. Do NOT include relationship fields here β€” use relationships instead.

entitystring

The type of record to create

Options:companypersondealusertask
Optional
relationshipsarray

Relationships to link, grouped by field name

Delete agent

delete-agent
Full Description

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
Parameters (1 required)
Required
agentIdstring

The ID of the agent to delete.

Delete artifact

delete-artifact
Full Description

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 β€” if you only have its name, find it first with query-data (entity "artifact", matching name with ILIKE).

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.

Parameters (1 required)
Required
artifact_idstring

The ID of the artifact to delete.

Cancel calendar event

delete-calendar-event
Full Description

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.
Parameters (1 required, 1 optional)
Required
event_idstring

The Nylas event ID (from get-calendar-events) to cancel.

Optional
notify_participantsboolean

Send cancellation notices to attendees. Default: true.

Default: True

Delete campaign

delete-campaign
Full Description

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
Parameters (1 required)
Required
campaign_idstring

The ID of the campaign to delete. Get this from get-campaigns tool.

Delete custom object

delete-custom-object
Full Description

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
Parameters (1 required)
Required
entitystring

The entity identifier of the custom object to delete (e.g., "c_sales_order").

Delete fields

delete-fields
Full Description

Delete one or more custom fields from an existing object (built-in entities like person, company, deal, meeting, task, 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
Parameters (2 required)
Required
entitystring

Entity identifier (e.g., "person", "company", "deal", "c_my_object")

fieldNamesarray

Field slugs to delete (e.g., ["linkedin_url", "company_size"])

Delete list

delete-list
Full Description

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
Parameters (2 required)
Required
entitystring

The type of record the list is for

list_idstring

The ID of the list to delete.

Delete records

delete-records
Full Description

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.
Parameters (2 required)
Required
entitystring

The type of records to delete. All records must be the same entity type.

record_idsarray

Array of record IDs to delete. All must be the same entity type.

Find leads

find-leads
Full Description

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:

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

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

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

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.

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

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:

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.

Parameters (1 required, 4 optional)
Required
entitystring

The type of lead to search for

Options:tam_companytam_person
Optional
modestring

Defaults to "draft". Use "draft" to run a query and create/refine a draft search. Use "publish" to save an existing draft search (provide search_identifiers.search_id) so it appears under "My saved searches"; sql and search_label are not needed when publishing.

Options:draftpublish
search_identifiersobject

Provide a search (and optionally a version) to operate on. Required in "publish" mode; in "draft" mode, provide it to refine an existing search.

search_labelstring

Label for the search (e.g., "SF Companies 50+ employees"). This will be the name of the saved search. Required in "draft" mode.

sqlstring

SQL query to find leads. Required in "draft" mode.

Get agent runs

get-agent-runs
Full Description

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.

Parameters (1 required, 3 optional)
Required
agent_idstring

The ID of the agent whose runs to list or fetch.

Optional
limitinteger

Maximum number of runs to return when listing.

Default: 10
offsetinteger

Offset for pagination when listing.

Default: 0
run_idstring

Optional ID of a specific run to fetch in full, including transcript. Omit to list recent runs.

Get agents

get-agents
Full Description

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
Parameters (0 required, 1 optional)
Optional
agentIdstring

The ID of a specific agent to fetch. Omit to list all visible agents.

Get artifact component doc

get-artifact-component-doc
Full Description

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.

Parameters (1 required)
Required
specifiersarray

One or more component specifiers to document, exactly as listed in the create-or-update-artifact tool (e.g. `["@clarify/ui/button", "@clarify/ui/card"]`). Batch everything you need in one call.

Get artifact source

get-artifact-source
Full Description

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.

Check while you're here

The saved source may carry defects. When you edit it, fold every clear fix into the same new version. The "Self-check" in the "artifact-docs" context lists the defects: dead code, broken math, manual work a built-in covers, missing contract, slow queries. Read it with read-context if you have not in this conversation.

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.

Pagination

A large source is returned one window at a time. When the window does not reach the end, the result gives the total character count and the offset to pass on the next call. Keep calling with the reported offset until you have read the region you need. This is how you reach the tail of a source too large to return in one response, so you can patch it with create-or-update-artifact.

Parameters (1 required, 1 optional)
Required
artifact_idstring

The ID of the artifact whose current source to read.

Optional
offsetinteger

Character offset to start reading from. Omit to read from the start. A large source is returned in windows; when one is truncated the result tells you the next offset to pass.

Get calendar events

get-calendar-events
Full Description

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.

Parameters (2 required, 2 optional)
Required
endstring

ISO 8601 end of the time range (exclusive)

startstring

ISO 8601 start of the time range (inclusive)

Optional
limitinteger

Maximum events to return

Default: 25
querystring

Optional substring match against event title or description

Get campaign recipients

get-campaign-recipients
Full Description

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>

Parameters (1 required, 5 optional)
Required
campaign_idstring

The ID of the campaign to list recipients for. Get it from get-campaigns.

Optional
eventarray

Return only recipients who did at least one of these. Multiple values are OR-ed, so ["opened","clicked"] returns anyone who opened or clicked.

limitnumber

Maximum number of recipients to return.

Default: 10
offsetnumber

Use with limit for pagination.

Default: 0
searchstring

Case-insensitive substring match against recipient name or email address.

statusstring

Return only recipients in this delivery state.

Options:scheduledcompletedpausedfailedunsubscribed

Get campaigns

get-campaigns
Full Description

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, and who it sends as (the sending inbox's owner)

Detail mode (with campaign_id):

  • Full campaign details including all email steps with subjects, bodies (HTML with variables), and timing
  • Who created the campaign and who it sends as. These can differ when the campaign sends from a delegated inbox
  • Campaign-level performance: open rate, click-through rate, response rate, bounce rate
  • Per-email-step engagement: sent, opened, clicked, replied, bounced counts

Both modes, when the user has more than one inbox or a delegated one:

  • The inboxes they can send campaigns from, to pass as from_email to create-campaign or update-campaign

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, unless the user asks to keep the original inbox and it is one they can send from (then pass it as from_email). If the copy sends from a different inbox than the original, also remove the original sender's personal sign-off from email bodies.

Parameters (0 required, 5 optional)
Optional
campaign_idstring

The ID of a specific campaign to fetch with full details. Omit to list all campaigns.

limitnumber

Maximum number of campaigns to return. List mode only.

Default: 10
offsetnumber

Use with limit for pagination. List mode only.

Default: 0
searchstring

Optional search query for case-insensitive substring matching against campaign name and description. List mode only.

statusstring

Filter campaigns by status (draft or active). List mode only.

Options:activedraft

Get current user

get-current-user
Full Description

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
Parameters (0 required, 1 optional)
Optional
mcp_client_timezonestring

Optional: IANA timezone string from the MCP client (e.g., "America/Los_Angeles"). If provided, will be used as fallback if user has not set a timezone preference.

Get lists

get-lists
Full Description

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")
Parameters (2 required, 6 optional)
Required
entitystring

The entity type to get lists for

Options:companypersondeal
tool_labelstring

Short description appended to "Read" for the final label (e.g., "deal lists" becomes "Read deal lists")

Optional
created_bystring

Filter to lists created by this user ID. Use get-current-user to resolve the current user.

limitnumber

Maximum number of lists to return

Default: 10
list_idstring

The ID of a specific list to fetch. Omit to list all matching lists.

offsetnumber

Use with limit for pagination.

Default: 0
owner_idstring

Filter to lists owned by this user ID. Use get-current-user to resolve the current user.

searchstring

Optional search query for case-insensitive substring matching against list title and description

Get records

get-records
Full Description

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.
Parameters (2 required)
Required
entitystring

The type of records to retrieve

idsarray

Record IDs to retrieve. Use query-data to find IDs first, then pass them here.

Get schema

get-schema
Full Description

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.

A read renders each requested entity's columns plus join-table stubs for its relationships, enough to write JOINs. To read a related entity's own columns, request that entity too.

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.

Parameters (0 required, 3 optional)
Optional
entitiesarray

The entities to get schema for. Pass the specific entities you need (e.g. ["deal", "company"]); include "tam_company" or "tam_person" for lead columns. Omitting this returns every CRM entity, which is large and re-sent every turn. Avoid unless you need the whole model.

formatstring
Options:readwrite
Default: read
tool_labelstring

Short description appended to "Fetched" for the final label

Default: schema

Import leads

import-leads
Full Description

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.

Parameters (4 required, 3 optional)
Required
searchEmojistring

A single emoji representing the theme of the search (e.g., "🏒" for companies, "πŸŒ‰" for SF-related searches)

searchIdstring

ID of the lead search to import from (the searchId from the search artifact)

searchTitlestring

A descriptive title for the search based on its current filters (e.g., "SF Companies with 50+ employees"). The source search will be renamed to this title.

sourceEntitystring
Options:tam_companytam_person
Optional
countinteger

Number of leads to import. When omitted, imports all leads (up to the max window). Use this when the user specifies a quantity, e.g. "import 100".

extraFieldsarray

TAM field names to import beyond the default fields. Use when the user requests specific additional fields (e.g., "industries", "tech_stack_products", "seniority"). See the extra fields section in the tool description for available field names per entity type.

versionIdstring

Prefer the versionId from the document context when available. If the document context has no versionId, use the latest versionId from the conversation when contextually appropriate.

Import meeting transcript

import-meeting-transcript
Full Description

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
Parameters (3 required)
Required
meeting_idstring

The Clarify meeting ID to attach the transcript to

sourcestring

The transcript source provider

Options:granolanotioncircleback
source_idstring

The ID of the transcript in the source system. For Granola: meeting UUID. For Notion: page URL or UUID. For Circleback: numeric meeting ID (e.g. "7927294"), not the URL slug.

Merge records

merge-records
Full Description

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. Merge up to 50 groups of duplicates in one call. Before calling, confirm with the user which record in each group is the primary.

Parameters (1 required, 3 optional)
Required
entitystring

The type of records to merge

Optional
mergesarray

Groups of duplicates of the same entity to merge, each a primary plus the sources merged into it (1 to 50 groups). Each record may appear only once across all groups. Groups merge independently: a failed group is reported and the rest still go through.

primaryRecordIdstring

Deprecated, use merges. The ID of the record to keep.

sourceRecordIdsarray

Deprecated, use merges. IDs of records to merge in.

Query analytics

query-analytics
Full Description

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

  • Use actor.entity and actor.source_id to tell an automated actor (an

agent or an API key) from a human user, and group or exclude by them when an automation may skew a metric

  • m_stage (String) β€” Materialized JSONExtractString(properties,'stage'); empty if absent
  • m_amount (Float64) β€” Materialized JSONExtractFloat(properties,'amount'); 0 if absent
  • m_close_date (Nullable(Date)) β€” Materialized toDateOrNull(properties.close_date);

NULL if absent/unset. A native date β€” range-filter and group it directly (m_close_date >= '2026-01-01', toStartOfMonth(m_close_date)) with no toDate wrapper

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:access-request-created, clarify:access-request-decided, 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.

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.

-- Deals that ENTERED a closed stage inside the window.
WITH closed AS (
SELECT entity_id, timestamp, m_stage AS stage_at_event
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
AND type = 'clarify:update'
AND arrayExists(d -> JSONExtractString(d, 'path', 1) = 'stage'
                  AND JSONExtractString(d, 'val') IN ('Won', 'Lost'),
                JSONExtractArrayRaw(diff))
UNION ALL
-- diff is empty on create: catches deals imported directly into a closed stage
SELECT entity_id, timestamp, m_stage
FROM analytics.event
WHERE workspace_slug = '<your workspace>' AND entity_type = 'deal'
AND type = 'clarify:create' AND m_stage IN ('Won', 'Lost')
)
SELECT countIf(final_stage = 'Won') AS deals_won,
   count() AS deals_closed,
   countIf(final_stage = 'Won') / nullIf(count(), 0) AS win_rate
FROM (SELECT entity_id, argMax(stage_at_event, timestamp) AS final_stage
  FROM closed GROUP BY entity_id)

The outer argMax collapses each deal to its last transition in the window, so a deal that goes Won β†’ Lost inside it counts once, as Lost. There is no delete guard here on purpose β€” a deal won in the window was won even if the record was later deleted; to drop since-deleted deals, reconstruct current state (above) and keep only the entity_ids that are still live. Matching on val works because stage is not sensitive; a sensitive field's val reads 'REDACTED' (its path is kept), so for those match on path presence alone.

For a "closed per month" trend, bucket by the event timestamp β€” that is when the transition actually happened. One data limitation to surface, not hide: on a batch-imported workspace every clarify:create lands on the import date, so deals that closed before they were imported collapse onto that date β€” the log has no real historical close date for them. m_close_date does not fill that gap: it is the *expected* close date (often in the future), right for a pipeline-by-expected-close forecast but wrong as a "when did we win" bucket.

When you do bucket a retrospective metric by a date field that can run into the future (m_close_date, any custom date field), cap the buckets to the current period β€” AND m_close_date <= today(), or HAVING bucket <= toStartOfMonth(today()). A future bucket holds only open pipeline, so a "what happened" metric (win rate, deals won) reads there as losses or zeros. Drop the cap only for an intentional forward-looking pipeline/forecast.

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.

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.

  • Summed or counted returned rows by hand β€” you totaled values in your

reasoning instead of in the query. Push it into the query (sum(...), count(), avg(...), countIf(...)) over the reconstructed CTE, and read the total from the result.

  • Pasted a literal id list into IN ('…','…') β€” you transcribed ids from a

prior result. Filter with a subquery instead: entity_id IN (SELECT <fk> FROM <cte>) (the join pattern above).

  • JSONExtractFloat64 does not exist β€” use JSONExtractFloat.
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>

Parameters (1 required, 1 optional)
Required
sqlstring
Optional
maxResultRowsnumber

Maximum number of rows to return. Results are capped to this many rows server-side, regardless of any LIMIT in your query.

Default: 1024

Query data

query-data
Full Description

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'
  • 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>.
  • Emails related to a deal: JOIN person_deal (deal -> person) then person_message: deal -> person_deal -> person -> person_message -> message.
  • Meetings related to a deal: JOIN person_deal (deal -> person) then person_meeting: deal -> person_deal -> person -> person_meeting -> meeting.
  • 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.

Parameters (3 required, 3 optional)
Required
entitystring

The entity type to query

Options:companypersondealmeetingusertaskmeeting_recordingmessage
sqlstring
tool_labelstring

Short description appended to "Read" for the final label (e.g., "deals data" becomes "Read deals data")

Optional
enrichboolean

If true, fetches rich context including AI summaries, relationships, and transcripts. Use for detailed record understanding. If false (default), returns raw query results. Use for analytics, counting, and aggregations.

Default: False
limitnumber

Maximum records to return, 10 by default. The smaller of this and any SQL LIMIT applies, so LIMIT 1 returns one record. Set it to match a count the user asks for (e.g., "last 5 meetings").

Default: 10
offsetnumber

Use with limit for pagination.

Default: 0

Read product knowledge

read-context
Full Description

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.
  • list-docs: How to write a list's defining SQL: title alias, showing to-one relationships, and column restrictions
Parameters (1 required)
Required
contextstring

The name of the context documentation to read

Options:field-docscalendar-docscampaign-docsartifact-docslist-docs

Respond to calendar event

respond-to-calendar-event
Full Description

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).
Parameters (2 required)
Required
event_idstring

The Nylas event ID (from get-calendar-events) to respond to.

statusstring

RSVP status: yes (accept), no (decline), or maybe (tentative).

Options:yesnomaybe

Send email

send-email
Full Description

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
Parameters (3 required, 6 optional)
Required
bodystring

Email body. Plain text by default (Follow the system prompt and any activated email-writing skill for the user's preferred voice, tone, structure, and formatting judgment; if no more specific guidance applies, write like a normal compact email, not a report: avoid em dashes and decorative separators, use at most one blank line between sections or paragraphs, keep section labels attached to their content, and keep lists attached to the sentence or item they explain. Lines starting with "- " become bullet items, "1. " etc. become numbered items, "# " or "## " become bold section labels, blank lines become paragraph breaks, **bold** becomes bold text, markdown links [label](url) or bare URLs become clickable links, and horizontal rules like "---" are not shown.); when isHtml is true, pass a complete, self-contained HTML document with inline styles instead.

subjectstring

Email subject line

toarray

Email addresses to send to

Optional
bccarray

Email addresses to BCC

ccarray

Email addresses to CC

confirmedNewRecipientsarray

Recipient addresses that match no CRM person record but that the user supplied or confirmed verbatim (e.g. a brand-new contact). Only set after the user gives you the address; never echo an address you generated yourself here to bypass the recipient check.

fromstring

Address to send from. This also picks which connected account the email is sent through: it may be any of the user's connected accounts' primary address or one of that account's configured aliases, or the address of an account a teammate gave the user delegated sending access to. Only set it when the user explicitly asks to send from a specific account or alias (e.g. "send from sam@hackathonradar.com" or "send from my work account", or "send this from Patrick's account"); otherwise omit to use the default account's primary address. Must be a connected account's address, a configured alias, or a delegated account's address, or the send is rejected.

isHtmlboolean

Set to true when body is a complete hand-authored HTML email (e.g. a branded, table-based layout). The HTML is sanitized and sent verbatim. Omit or set false for the default plain-text body that gets converted to rich text.

replyToMessageIdstring

Short Nylas message_id of the inbound email being replied to (e.g. 19df41adc5abe23b). Set this when sending a reply on an existing thread so the message appears threaded 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). Do not pass a Clarify record _id or a Nylas draft id here.

Submit feedback

submit-feedback
Full Description

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.

Parameters (1 required, 1 optional)
Required
feedbackstring

The feature request or feedback message describing what functionality is needed or what could be improved

Optional
categorystring

Category of feedback to help with prioritization

Options:feature_requestbug_reportimprovementother

Update campaign

update-campaign
Full Description

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. A live campaign (enabled or already sent) accepts only new_name and send_windows. Its steps, list, and sender are locked

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
Parameters (1 required, 6 optional)
Required
campaign_idstring

Campaign ID to update. Get from get-campaigns tool.

Optional
email_stepsarray

Email step operations (insert, remove, update). Each step must specify an operation.

from_emailstring

Move the campaign to another sending inbox: one of the user's own connected accounts, or an inbox another member delegated to them. get-campaigns lists the inboxes they can send from. Changes every email's sending account, From address, and signature together. Drafts only. Omit to keep the current inbox.

from_namestring

Update sender display name for all emails in the campaign

list_idstring

Target list ID. Get from get-lists tool.

new_namestring

New campaign name

send_windowsobject

Send time windows for the campaign. Pass null to disable windows (emails can be sent at any time) or a { windows: { <day>: { start, end } | null } } object to configure them. Days are lowercase (sunday-saturday); start/end are "HH:mm" 24-hour strings with start < end. Omit to leave existing windows unchanged.

Update record

update-record
Full Description

Update a record by its ID in Clarify. This should be used only if you already have the ID for the record.

Parameters (3 required, 1 optional)
Required
attributesobject

Non-relationship field values to update. Do NOT include relationship fields here β€” use relationships instead.

entitystring

The type of record to update

Options:companypersondealusertaskmeeting
idstring

The ID of the record to update

Optional
relationshipsarray

Relationships to link, grouped by field name