AgentSkillsCN

query-language

当用户需要进行复杂的数据查询、多实体联结、聚合计算,或对Affinity数据展开分析时,可选用此技能。此外,当用户希望通过编程方式对CRM记录进行筛选、分组、排序或聚合时,亦可使用此技能。触发条件包括:“查询语言”“结构化查询”“类似SQL”“查找所有符合……的人”“按……统计机会数量”“汇总交易金额”“计算平均值”“按状态分组”“使用AND/OR进行筛选”“包含拥有人员的企业”。

SKILL.md
--- frontmatter
name: query-language
description: Use when user needs complex data queries, multi-entity joins, aggregations, or analysis across Affinity data. Also use when user wants to filter, group, sort, or aggregate CRM records programmatically. Triggers: "query language", "structured query", "SQL-like", "find all persons where", "count opportunities by", "sum deal values", "average amount", "group by status", "filter AND/OR", "include companies with persons".

Affinity Query Language

This skill covers the structured query language for querying Affinity CRM data via the query MCP tool.

⚠️ Before running queries: Complete the pre-flight checklist from xaffinity://workflows-guide (read data-model, run discover-commands, state what you learned). This ensures you use current syntax and don't miss useful flags.

When to Use This Tool

Use the query tool instead of individual CLI commands when you need:

  • Complex filtering with multiple conditions (AND, OR, NOT)
  • Include relationships (e.g., get persons with their companies)
  • Aggregations (count, sum, avg, min, max, percentile)
  • Grouping (count opportunities by status)
  • Multi-field sorting
  • Batch analysis across large datasets

For simple lookups, prefer execute-read-command with individual commands.

Quick Start

json
// Simplest query - get 10 persons
{"from": "persons", "limit": 10}

// Add a filter
{"from": "persons", "where": {"path": "email", "op": "contains", "value": "@acme.com"}, "limit": 10}

// Include related companies
{"from": "persons", "include": ["companies"], "limit": 10}

// Query list entries
{"from": "listEntries", "where": {"path": "listName", "op": "eq", "value": "Dealflow"}, "limit": 10}

Query Structure

json
{
  "$version": "1.0",
  "from": "persons",
  "where": { "path": "email", "op": "contains", "value": "@acme.com" },
  "include": ["companies", "opportunities"],
  "select": ["id", "firstName", "lastName", "email"],
  "orderBy": [{ "field": "lastName", "direction": "asc" }],
  "limit": 100
}

Required Fields

FieldDescription
fromEntity type: persons, companies, opportunities, listEntries, interactions, notes

Optional Fields

FieldDescription
$versionQuery format version (default: "1.0")
whereFilter conditions
includeRelated entities to fetch
expandComputed data to add to records (e.g., interactionDates)
selectFields to return (default: all)
orderBySort order
groupByField to group by (requires aggregate)
aggregateAggregate functions to compute
havingFilter on aggregate results
limitMaximum records to return

Filter Operators

Comparison Operators

OperatorDescriptionExample
eqEqual{"path": "status", "op": "eq", "value": "Active"}
neqNot equal{"path": "status", "op": "neq", "value": "Closed"}
gtGreater than{"path": "amount", "op": "gt", "value": 10000}
gteGreater than or equal{"path": "amount", "op": "gte", "value": 10000}
ltLess than{"path": "amount", "op": "lt", "value": 5000}
lteLess than or equal{"path": "amount", "op": "lte", "value": 5000}

String Operators

OperatorDescriptionExample
containsContains substring (case-insensitive){"path": "email", "op": "contains", "value": "@gmail"}
starts_withStarts with (case-insensitive){"path": "name", "op": "starts_with", "value": "Acme"}
ends_withEnds with (case-insensitive){"path": "email", "op": "ends_with", "value": "@acme.com"}

Collection Operators

OperatorDescriptionExample
inValue in list{"path": "status", "op": "in", "value": ["New", "Active"]}
betweenValue in range{"path": "amount", "op": "between", "value": [1000, 5000]}
contains_anyString contains any substring (case-insensitive){"path": "bio", "op": "contains_any", "value": ["python", "java"]}
contains_allString contains all substrings (case-insensitive){"path": "bio", "op": "contains_all", "value": ["senior", "engineer"]}
has_anyArray field contains any of the values{"path": "fields.Team Member", "op": "has_any", "value": ["LB", "MA"]}
has_allArray field contains all of the values{"path": "fields.Team Member", "op": "has_all", "value": ["LB", "MA"]}

Multi-Select Field Filtering

Multi-select dropdown fields (like "Team Member") return arrays from the API. The eq and neq operators handle these automatically:

OperatorSingle-value fieldMulti-select field
eqExact matchScalar: membership check / List: set equality
neqNot equalScalar: not in array / List: set inequality
inValue in listAny intersection between arrays
has_anyReturns falseAny specified value present
has_allReturns falseAll specified values present

Examples:

json
// Find entries where Team Member includes "LB"
{ "path": "fields.Team Member", "op": "eq", "value": "LB" }

// Find entries where Team Member includes any of ["LB", "DW"]
{ "path": "fields.Team Member", "op": "has_any", "value": ["LB", "DW"] }

// Find entries where Team Member includes both "LB" and "MA"
{ "path": "fields.Team Member", "op": "has_all", "value": ["LB", "MA"] }

Null/Empty Checks

OperatorDescriptionExample
is_nullField is null or empty string{"path": "email", "op": "is_null"}
is_not_nullField is not null and not empty{"path": "email", "op": "is_not_null"}
is_emptyField is null, empty string, or empty array{"path": "emails", "op": "is_empty"}

Compound Conditions

AND

json
{
  "where": {
    "and": [
      { "path": "status", "op": "eq", "value": "Active" },
      { "path": "amount", "op": "gt", "value": 10000 }
    ]
  }
}

OR

json
{
  "where": {
    "or": [
      { "path": "email", "op": "contains", "value": "@acme.com" },
      { "path": "email", "op": "contains", "value": "@acme.io" }
    ]
  }
}

NOT

json
{
  "where": {
    "not": { "path": "status", "op": "eq", "value": "Closed" }
  }
}

Advanced Filtering (Quantifiers, Exists, Count)

Filter based on related entities using quantifiers and existence checks.

Include vs Quantifiers: Use include to get related data in the response (e.g., "include": ["companies"]). Use quantifiers to filter by related data (e.g., {"path": "companies._count", "op": "gte", "value": 2}). You can use both together.

ALL Quantifier

All related items must match the condition:

json
{
  "from": "persons",
  "where": {
    "all": {
      "path": "companies",
      "where": { "path": "domain", "op": "contains", "value": ".com" }
    }
  }
}

Note: Returns true for records with no related items (vacuous truth). To require at least one, combine with _count:

json
{
  "where": {
    "and": [
      { "path": "companies._count", "op": "gte", "value": 1 },
      { "all": { "path": "companies", "where": { "path": "domain", "op": "contains", "value": ".com" }}}
    ]
  }
}

NONE Quantifier

No related items may match the condition:

json
{
  "from": "persons",
  "where": {
    "none": {
      "path": "interactions",
      "where": { "path": "type", "op": "eq", "value": "spam" }
    }
  }
}

EXISTS Clause

At least one related item exists (optionally matching a filter):

json
// Simple existence check
{
  "from": "persons",
  "where": { "exists": { "from": "interactions" }}
}

// With filter
{
  "from": "persons",
  "where": {
    "exists": {
      "from": "interactions",
      "where": { "path": "type", "op": "eq", "value": "meeting" }
    }
  }
}

Count Pseudo-Field

Count related items and compare:

json
// Persons with 2 or more companies
{
  "from": "persons",
  "where": { "path": "companies._count", "op": "gte", "value": 2 }
}

// Persons with no interactions
{
  "from": "persons",
  "where": { "path": "interactions._count", "op": "eq", "value": 0 }
}

Available Relationships for Quantifiers

From EntityAvailable Relationship Paths
personscompanies, opportunities, interactions, notes, listEntries
companiespersons, opportunities, interactions, notes, listEntries
opportunitiespersons, companies, interactions

Limitations

  • Nested quantifiers not supported: Cannot use all/none/exists inside another quantifier
  • N+1 API calls: Quantifiers fetch relationship data for each record (use dry-run to preview)

Include Relationships

Fetch related entities in a single query:

json
{
  "from": "persons",
  "include": ["companies", "opportunities"],
  "limit": 50
}

Available Relationships

FromCan Include
personscompanies, opportunities, interactions, notes, listEntries
companiespersons, opportunities, interactions, notes, listEntries
opportunitiespersons, companies, interactions
listsentries
listEntriesentity, persons, companies, opportunities, interactions

Note: For listEntries:

  • entity dynamically resolves to person/company/opportunity based on entityType
  • persons, companies, opportunities, interactions fetch related entities for each list entry

Include Output Format

Included data appears in a separate included section keyed by relationship name:

json
{
  "data": [{"id": 123, "firstName": "John", "organizationIds": [456]}],
  "included": {
    "companies": [{"id": 456, "name": "Acme Inc", "domain": "acme.com"}]
  }
}

In markdown format, included data appears as separate tables with headers like "Included: companies".

Note: Parent records reference included entities via ID fields (e.g., organizationIds for companies). The included section contains deduplicated records.

Expand Computed Data

Unlike include (which fetches related entities), expand adds computed data directly to each record.

⚠️ ALWAYS use dryRun: true first before running expand queries to see estimated API calls:

json
// STEP 1: Preview with dryRun
{"query": {"from": "companies", "expand": ["interactionDates"], "limit": 50}, "dryRun": true}

// STEP 2: If API calls look reasonable (<200 calls), run without dryRun
{"from": "companies", "expand": ["interactionDates"], "limit": 50}

Available Expansions

ExpansionSupported EntitiesDescription
interactionDatespersons, companies, listEntriesLast/next meeting dates, email dates, team members
unrepliedpersons, companies, opportunities, listEntriesDetect unreplied incoming messages - email/chat (date, daysSince, type, subject)

Interaction Dates Output

When using expand: ["interactionDates"], each record includes:

json
{
  "id": 123,
  "name": "Acme Corp",
  "interactionDates": {
    "lastMeeting": {
      "date": "2026-01-08T10:00:00Z",
      "daysSince": 5,
      "teamMembers": ["Bob Smith", "Carol Jones"]
    },
    "nextMeeting": {
      "date": "2026-01-20T14:00:00Z",
      "daysUntil": 7,
      "teamMembers": ["Alice Wong"]
    },
    "lastEmail": {
      "date": "2026-01-10T09:30:00Z",
      "daysSince": 3
    },
    "lastInteraction": {
      "date": "2026-01-10T09:30:00Z",
      "daysSince": 3
    }
  }
}

Include vs Expand

Featureincludeexpand
PurposeFetch related entitiesAdd computed data to records
OutputSeparate included sectionMerged into each record
Exampleinclude: ["companies"] → company recordsexpand: ["interactionDates"] → dates on each record

Parameterized Includes for listEntries

When including interactions for listEntries, you can customize the fetch with parameters:

json
{
  "from": "listEntries",
  "where": {"path": "listName", "op": "eq", "value": "Dealflow"},
  "include": [
    {"interactions": {"limit": 50, "days": 180}},
    {"opportunities": {"list": "Pipeline"}}
  ]
}
ParameterDescriptionDefault
limitMax interactions per entity100
daysLookback window in days90
listScope opportunities to specific list name/IDAll
whereFilter included entitiesNone

Example: Pipeline with Interaction Dates

json
{
  "from": "listEntries",
  "where": {"path": "listName", "op": "eq", "value": "Dealflow"},
  "expand": ["interactionDates"],
  "select": ["entityId", "entityName", "fields.Status"],
  "limit": 100
}

Note: expand causes N+1 API calls (one per record). Use dryRun: true to preview the cost.

Aggregations

Basic Aggregates

json
{
  "from": "opportunities",
  "aggregate": {
    "total": { "count": true },
    "totalValue": { "sum": "amount" },
    "avgValue": { "avg": "amount" },
    "minValue": { "min": "amount" },
    "maxValue": { "max": "amount" }
  }
}

Group By

json
{
  "from": "opportunities",
  "groupBy": "status",
  "aggregate": {
    "count": { "count": true },
    "totalValue": { "sum": "amount" }
  }
}

Having (Filter on Aggregates)

json
{
  "from": "opportunities",
  "groupBy": "status",
  "aggregate": {
    "count": { "count": true }
  },
  "having": { "path": "count", "op": "gte", "value": 5 }
}

Querying List Entries

listEntries requires either listId or listName filter:

json
// By ID
{"from": "listEntries", "where": {"path": "listId", "op": "eq", "value": 12345}}

// By name (executor resolves name → ID at runtime)
{"from": "listEntries", "where": {"path": "listName", "op": "eq", "value": "Dealflow"}}

Invalid paths: list.name, list.id - use listName or listId directly.

Note: When using listName, the query executor looks up the list by name and resolves it to a listId before fetching entries. This adds one API call but allows using human-readable names.

Custom Field Values

When querying listEntries with groupBy, aggregate, or where on fields.* paths, the query engine automatically detects which fields are referenced and requests their values from the API.

json
{
  "from": "listEntries",
  "where": {"path": "listName", "op": "eq", "value": "Dealflow"},
  "groupBy": "fields.Status",
  "aggregate": {"count": {"count": true}}
}

Best practice: Select only the fields you need:

json
{
  "from": "listEntries",
  "where": {"path": "listName", "op": "eq", "value": "Dealflow"},
  "select": ["entityName", "fields.Status", "fields.Owner"],
  "limit": 100
}

⚠️ Performance warning: The fields.* wildcard fetches ALL custom field values. For lists with many fields (50+), this can take 60+ seconds per API page. Only use fields.* when you genuinely need every field - otherwise select specific fields like fields.Status, fields.Owner.

Available Select Fields

FieldDescription
listEntryIdList entry ID (same as id)
entityIdID of the company/person/opportunity
entityNameName of the entity
entityType"company", "person", or "opportunity"
listIdParent list ID
createdAtEntry creation timestamp
fields.<Name>Custom field value by name (preferred)
fields.*All custom fields (⚠️ slow for lists with 50+ fields)

Field Value Normalization

Reference field values are normalized to display strings for readability:

Field TypeAPI ReturnsNormalized To
Dropdown{"text": "Active", "id": 1}"Active"
Multi-select[{"text": "A"}, {"text": "B"}]["A", "B"]
Person reference{"firstName": "Jane", "lastName": "Doe"}"Jane Doe"
Company reference{"name": "Acme Corp", "id": 456}"Acme Corp"

Note: Use expand or include to get full entity objects when you need IDs or other properties.

Field Paths

Access nested fields using dot notation:

json
{
  "from": "listEntries",
  "where": { "path": "fields.Status", "op": "eq", "value": "Active" }
}

Common paths:

  • fields.<FieldName> - Custom list fields on listEntries (preferred - select specific fields)
  • fields.* - All custom fields (⚠️ avoid for lists with 50+ fields - very slow)
  • emails[0] - First email in array
  • company.name - Nested object field (on included relationships)

Date Filtering

Relative Dates

json
{
  "from": "interactions",
  "where": { "path": "created_at", "op": "gte", "value": "-30d" }
}

Supported formats:

  • -30d - 30 days ago
  • +7d - 7 days from now
  • today - Start of today
  • now - Current time
  • yesterday - Start of yesterday
  • tomorrow - Start of tomorrow

Dry-Run Mode

⚠️ MANDATORY for expand/include queries: Always use dryRun: true first to preview API cost.

When to use dryRun:

  • ✅ ALWAYS before any query with expand or include
  • ✅ ALWAYS before quantifier queries (all, none, exists, _count)
  • ✅ Before large result sets (100+ records)
  • Optional for simple filters without relationships
json
{
  "query": {
    "from": "persons",
    "include": ["companies", "opportunities"]
  },
  "dryRun": true
}

Returns execution plan with:

  • Estimated API calls (key metric - if >200, consider reducing limit)
  • Estimated records
  • Step breakdown
  • Warnings about expensive operations

Decision guide based on dryRun results:

API CallsAction
<100✅ Safe to run
100-200⚠️ Will take 2-5 minutes
200-400⚠️ May take 5-10 minutes, near ceiling
400+❌ Reduce limit or batch the query

Examples

Find VIP Contacts

json
{
  "from": "persons",
  "where": {
    "and": [
      { "path": "email", "op": "is_not_null" },
      { "path": "fields.VIP", "op": "eq", "value": true }
    ]
  },
  "include": ["companies"],
  "orderBy": [{ "field": "lastName", "direction": "asc" }],
  "limit": 100
}

Pipeline Summary by Status

json
{
  "from": "listEntries",
  "where": { "path": "listId", "op": "eq", "value": 12345 },
  "groupBy": "fields.Status",
  "aggregate": {
    "count": { "count": true },
    "totalValue": { "sum": "fields.Deal Value" }
  }
}

Recent Interactions

json
{
  "from": "interactions",
  "where": {
    "and": [
      { "path": "created_at", "op": "gte", "value": "-7d" },
      { "path": "type", "op": "in", "value": ["call", "meeting"] }
    ]
  },
  "include": ["persons"],
  "orderBy": [{ "field": "created_at", "direction": "desc" }],
  "limit": 50
}

Companies Without Recent Activity

json
{
  "from": "companies",
  "where": {
    "or": [
      { "path": "lastInteraction.date", "op": "lt", "value": "-90d" },
      { "path": "lastInteraction.date", "op": "is_null" }
    ]
  },
  "limit": 100
}

Tool Parameters

ParameterTypeDefaultDescription
queryobjectrequiredThe JSON query object
dryRunbooleanfalsePreview execution plan without running
maxRecordsinteger1000Safety limit (max 10000). Same limits enforced on CLI commands.
timeoutintegerautoQuery timeout in seconds (auto-calculated from estimated API calls if not specified)
maxOutputBytesinteger50000Truncation limit for results
formatstring"toon"Output format (see Output Formats below)
cursorstringnullResume from previous truncated response (see Truncated Responses below)

Truncated Responses

When output exceeds maxOutputBytes, the response includes truncated: true and a nextCursor. Pass this cursor to continue from the truncation point:

json
// Response with truncation
{
  "data": [...],
  "truncated": true,
  "nextCursor": "eyJ2IjoxLC...",
  "_cursorMode": "streaming"
}

// Resume with cursor (keep query and format identical)
{
  "query": {"from": "persons", "limit": 1000},
  "format": "toon",
  "cursor": "eyJ2IjoxLC..."
}

Important: The nextCursor is for output size truncation, not record limits. A query returning all requested records won't have a nextCursor unless the output was too large. See "Handling Truncated Responses" in xaffinity://data-model for details.

Timeout Auto-Calculation

When timeout is not specified, it's automatically calculated based on the query's estimated API calls:

  • Formula: ~2 seconds per API call, minimum 30 seconds
  • Example: Query with 100 API calls → 200 second timeout

The auto-calculation runs a quick dry-run internally to estimate API calls, then sets an appropriate timeout. Specify timeout explicitly to override.

Output Formats

The format parameter controls how results are returned. Choose based on your use case:

FormatToken EfficiencyBest ForDescription
toonHigh (~40% fewer)Default - large datasetsFull envelope with data, pagination, included
jsonLowProgrammatic useFull JSON structure (same data as TOON)
markdownMedium-HighLLM analysisGitHub-flavored table + pagination footer (best comprehension)
jsonlMediumStreamingOne JSON object per line (data only)
csvMediumSpreadsheetsComma-separated values (data only)

Format Recommendations

  • For LLM analysis tasks: Use markdown - LLMs are trained on documentation and tables
  • For large result sets: Use toon to minimize tokens (30-60% smaller than JSON)
  • For programmatic processing: Use json for full structure
  • For streaming workflows: Use jsonl for line-by-line processing

Format Examples

JSON:

json
{"data": [{"id": 1, "name": "Acme"}], "included": {...}, "pagination": {...}}

JSONL:

jsonl
{"id": 1, "name": "Acme"}
{"id": 2, "name": "Beta"}

Markdown:

markdown
| id | name |
| --- | --- |
| 1 | Acme |
| 2 | Beta |

TOON (default):

code
data[2]{id,name}:
  1,Acme
  2,Beta
pagination:
  hasMore: false
  total: 2

Note: jsonl and csv are data-only export formats (no envelope). toon, json, and markdown preserve pagination and included entity information.

Truncated Response Example

When output exceeds maxOutputBytes, the response includes truncation metadata (shown for JSON format):

json
{
  "data": [{"id": 1, "name": "Acme"}, {"id": 2, "name": "Beta"}, ...],
  "executed": ["xaffinity", "query", "--output", "json", ...],
  "truncated": true,
  "nextCursor": "eyJ2IjoxLCJxaCI6IjZmYzJhZDJkYTI5...",
  "_cursorMode": "streaming"
}

For TOON format, truncation appears as:

code
data[56]{id,name}:
  ...
truncated: true
nextCursor: eyJ2IjoxLCJxaCI6IjZmYzJhZDJkYTI5...
_cursorMode: streaming

Performance

Expand InteractionDates

The interactionDates expansion fetches meeting/email dates and team member names for each record. Performance optimizations:

  • Parallel fetching: Entity fetches and person name resolution run in parallel
  • Shared concurrency limits: Person API calls are bounded to prevent rate limiting
  • Graceful degradation: If person name lookup fails, falls back to "Person {id}" instead of failing the query
  • Progress reporting: Shows per-record progress for large expansions

Recommendations:

  • For large datasets (500+ records), expect ~2 seconds per record
  • Use limit to scope the expansion appropriately
  • The timeout auto-calculates based on estimated API calls

Environment Variables

For advanced tuning (power users only):

VariableDefaultDescription
XAFFINITY_QUERY_CONCURRENCY15Max concurrent API calls for fetches/expansions

Best Practices

  1. Start with dry-run for complex queries to see API call estimates
  2. Use limit to avoid fetching too much data
  3. Be specific with where to reduce client-side filtering
  4. Avoid deep includes which cause N+1 API calls
  5. Use groupBy + aggregate for reports instead of fetching all records
  6. For quantifier queries on large databases, always add maxRecords

Quantifier Query Performance

Quick decision:

  • listEntries → Safe (bounded by list size)
  • persons/companies/opportunities with quantifiers → Requires maxRecords

Important: Queries using all, none, exists, or _count on unbounded entities (persons, companies, opportunities) require explicit maxRecords.

Why? These operations make N+1 API calls (one per record). On a database with 50,000 persons, this could take 26+ minutes.

Recommended approach:

  1. Start from listEntries (bounded by list size) instead of unbounded entities
  2. Add cheap pre-filters before quantifier conditions to reduce N+1 calls
  3. Use maxRecords to explicitly limit scope: maxRecords: 100
  4. Use dryRun: true to preview estimated API calls before running

Example - safe quantifier query:

json
{
  "query": {
    "from": "listEntries",
    "where": {
      "and": [
        {"path": "listName", "op": "eq", "value": "Target Companies"},
        {"path": "persons._count", "op": "gte", "value": 3}
      ]
    }
  },
  "maxRecords": 1000
}

Limitations

  • All filtering except listEntries field filters happens client-side
  • Includes cause N+1 API calls (1 per parent record)
  • No cross-entity joins (use includes instead)
  • Maximum 10,000 records per query for safety
  • Nested quantifiers (all/none/exists inside each other) not supported
  • OR clauses containing quantifiers cannot benefit from lazy loading optimization