| name | xaffinity-cli-usage |
| description | Runs xaffinity CLI commands directly in bash to search, export, filter, and manage Affinity CRM data.
|
| when_to_use | Use when the user explicitly asks about CLI commands, bash scripts, xaffinity flags, CSV export, or mentions "xaffinity" by name. Also use when MCP tools are not available and user needs CRM data access. Do NOT use for pipeline history analysis (use pipeline-history skill) or structured queries via MCP (use query-language skill) when those skills are available.
|
xaffinity CLI Usage
REQUIRED FIRST STEP: Verify API Key
STOP. Before doing ANYTHING else, run this command:
xaffinity config check-key --json
This MUST be your first action when handling any Affinity request.
If "configured": true - Use the pattern field from the output for ALL subsequent commands:
- If
"pattern": "xaffinity --dotenv --readonly <command> --json" -> use --dotenv
- If
"pattern": "xaffinity --readonly <command> --json" -> no --dotenv needed
If "configured": false - Stop and help user set up. The xaffinity CLI resolves the API key in this order:
AFFINITY_API_KEY env var
AFFINITY_API_KEY_FILE env var (path to a file containing the key — Docker secrets / k8s convention)
AFFINITY_API_KEY_COMMAND env var (shell command whose stdout is the key — git-credential-helper style; works with op / pass / vault / macOS security)
--api-key-file <path> or --api-key-stdin CLI flags
xaffinity config setup-key config file (saved to system keychain on supported platforms)
For most users:
- Tell them: "You need to configure an Affinity API key first."
- Direct them: Affinity -> Settings -> API -> Generate New Key
- Tell them to run:
xaffinity config setup-key (do NOT run it for them - it's interactive)
For users with an existing secret manager (1Password, vault, pass, Keychain), suggest AFFINITY_API_KEY_COMMAND as a credential-helper-style integration instead.
Cowork-specific edge case: if you are running inside a Claude Cowork session and the host CLI is configured but check-key returns configured: false, the key likely lives in a host-only location (env var, ~/.config/, keychain, host-only credential helper) that the microVM does not mount. Two host-portable options:
- Project
.env + --dotenv — create a project-scope .env file with AFFINITY_API_KEY=…. The project workdir IS mounted into the VM. This is the most common Cowork path.
AFFINITY_API_KEY_FILE — write the key to a file in the project workdir (e.g., .xaffinity-key, gitignored, chmod 600) and export AFFINITY_API_KEY_FILE=/path/to/.xaffinity-key. Equivalent reach as .env but works without --dotenv on the command line.
AFFINITY_API_KEY_COMMAND is generally NOT useful in Cowork because the helper binaries (op, pass, vault, security) typically aren't installed in the VM.
Session cache: Started automatically on first xaffinity use. If AFFINITY_SESSION_CACHE is not set, it will be initialized when you run your first xaffinity command — this shares metadata across commands and avoids redundant API calls.
Common pitfalls (READ THIS FIRST)
1. --json emits a single object, not NDJSON.
Parse with json.load(proc.stdout) and read data["data"]["rows"]. Do NOT
split on newlines — there's exactly one newline (the trailing one).
2. Never redirect stderr to /dev/null.
Every CLI safety signal — "results truncated", "unknown option",
"--filter client-side" — lives on stderr. Dropping stderr is how you ship a
silent-zero-match result and call it a duplicate check.
3. --filter on list export requires --all, --max-results, or
--first-page-only. The CLI errors on the unscoped case (v1.13+).
Filtering is client-side; for large lists prefer --saved-view (server-side)
or --company-id / --person-id (entity-scoped, cheap).
4. Duplicate checks: use --company-id / --person-id, not --filter.
xaffinity list export "Pipeline" --company-id 555 returns 0 or more rows
for that exact company. Zero rows + the emitted warning is the "not on list"
signal. No page-1 confusion possible.
5. Check meta.truncated on every JSON response.
If payload["meta"]["truncated"] is true, the answer is incomplete.
truncationReason names the cause (currently: firstPageOnly).
6. Side-effecting commands: capture, then parse.
A pipeline like
xaffinity note create --content "..." --company-id 123 | python3 -c "json.loads(...)"
runs the create first, then parses. If you forgot --json, the parser
crashes and the pipeline exits 1 — but the note was already created. The
agent then "retries" and creates a duplicate. Always capture output to a
variable first, then parse:
out=$(xaffinity --json note create --content "..." --company-id 123)
note_id=$(printf '%s' "$out" | jq -r '.data.note.id')
The CLI also emits a duplicate-note warning to stderr when an identical
content arrives within 5 minutes from the same author, but that's only a
safety net — capture-then-parse is the actual fix.
7. Person/company field values require numeric IDs, not names.
xaffinity list entry field 999 --set Owner "Jane Doe" aborts with an
"Invalid entity ID" error before any write. Resolve names to IDs first:
owner_id=$(xaffinity --readonly --json person ls --query "Jane Doe" \
| jq -r '.data.rows[0].id')
xaffinity list entry field 999 --set Owner "$owner_id"
As of v0.7 the CLI pre-validates ALL --set values before issuing any API
call, so a bad person id no longer leaves prior --set Status=... writes
committed. The lookup is still required though — the CLI does not
auto-resolve names.
IMPORTANT: Write Operations Require Explicit User Request
Always use --readonly unless user explicitly requests writes.
Write operations include creating, updating, or deleting:
- Notes, interactions, reminders
- List entries, field values
- Persons, companies, opportunities
Destructive Commands Require Double Confirmation
IMPORTANT: Before executing ANY delete command, you MUST:
- Look up the entity first to show the user what will be deleted
- Ask the user in your response by showing them the entity details and requesting confirmation
- Wait for user's next message - do NOT proceed until they explicitly confirm
- Only after user confirms should you run the delete with
--yes
Example flow:
User: "Delete person 123"
You: xaffinity --readonly person get 123 --json
You: "This will permanently delete John Smith (ID: 123, email: john@example.com).
Type 'yes' to confirm deletion."
[Stop here and wait for user's response]
User: "yes"
You: xaffinity person delete 123 --yes
Destructive commands: person delete, company delete, opportunity delete, note delete, reminder delete, field delete, list entry delete, interaction delete
Note: This is conversation-based confirmation - you ask, then wait for the user's next message. The --yes flag bypasses the CLI's interactive prompt, but you must get explicit user confirmation in the conversation first.
Critical Patterns
| Pattern | Purpose |
|---|
--readonly | Prevent accidental data modification (ALWAYS use unless writing) |
--json | Structured, parseable output (ALWAYS use for commands you will parse) |
--max-results N | Limit results (ALWAYS use on list/search commands). Aliases: --limit, -n |
--yes | Skip confirmation on delete commands (use after user confirms) |
--help | Discover command options (USE THIS, don't guess flags) |
IMPORTANT: Always limit results. Use --max-results on every ls, list export, interaction ls, and note ls command. Start small (10-50), increase only if needed. Unbounded queries can return hundreds of KB of data and make many API calls.
Extract only what you need. When you know which fields you need, pipe through jq instead of dumping the full JSON response. Skip this when exploring data for the first time.
xaffinity --readonly person get email:alice@example.com --json | jq -r '.data.person.id'
xaffinity --readonly person get 123 --json | jq '.data.person | {id, firstName, lastName, primaryEmail}'
xaffinity --readonly list export "Pipeline" --max-results 20 --json | jq '[.data.rows[] | {entityName, entityId}]'
Multi-Source Tasks: Use a Script
When a task needs data from 2 or more CLI commands (e.g., person details + interactions + list entries), write a single bash script instead of running commands one-by-one. Each separate command dumps its full JSON into the conversation — chaining 3-5 commands can waste hundreds of KB of context on raw data you only need a few facts from.
Use a script when: combining entity details with interactions, cross-referencing list entries with entities, generating summaries from multiple queries.
A single command is fine when: simple lookups (person get email:...), single writes (note create), quick searches (person ls --query).
Bash + jq
Session caching is already active (set up at session start), so just use jq to extract the summary:
CID=$(xaffinity --readonly company get domain:acme.com --json \
| jq -r '.data.company.id')
xaffinity --readonly interaction ls --type all --company-id "$CID" \
--after 2025-01-01T00:00:00Z --before 2025-03-31T23:59:59Z \
--max-results 200 --json \
| jq '{
company: "Acme",
total: (.data.interactions | length),
by_type: (.data.interactions | group_by(.type)
| map({type: .[0].type, count: length}))
}'
This outputs ~200 bytes instead of ~100 KB of raw JSON.
When to use Python instead
For complex joins across 3+ sources, conditional logic, pagination over large datasets, or when you need SDK features like F filters or FieldResolver, write a Python script using the Affinity SDK. The SDK skill has patterns for this.
Selectors: Use Names, Not Just IDs
Most commands accept names, emails, or domains directly — no need to look up IDs first:
xaffinity --readonly person get email:alice@example.com --json
xaffinity --readonly company get domain:acme.com --json
xaffinity --readonly list export "My Pipeline" --max-results 20 --json
xaffinity --readonly person get 12345 --json
Common Commands
xaffinity --readonly person ls --query "John Smith" --max-results 10 --json
xaffinity --readonly company ls --query "Acme" --max-results 10 --json
xaffinity --readonly person get email:alice@example.com --json
xaffinity --readonly company get domain:acme.com --json
xaffinity --readonly list export "Pipeline" --max-results 20 --json
JSON output key is data.rows (not data.listEntries or data.entries). Each row contains listEntryId, entityType, entityId, entityName, plus field values keyed by field name.
xaffinity --readonly list ls --json
xaffinity --readonly person ls --all --csv --csv-bom > contacts.csv
xaffinity --readonly list export "Pipeline" --all --csv --csv-bom > output.csv
List Entry Fields
Read or update field values on a list entry:
xaffinity --readonly list entry field "Pipeline" 12345 --get Owner --get Status --json
xaffinity list entry field "Pipeline" 12345 --set Status "Active"
--get returns resolved objects for person/company reference fields (with firstName, lastName, primaryEmailAddress) and full dropdown option data (with text, color).
Interactions
Interactions require --type and exactly one entity ID (--person-id, --company-id, or --opportunity-id).
Valid types: email, meeting, call, chat, chat-message, all
Date range: Defaults to all time if not specified. Use --days or --after/--before to limit.
xaffinity --readonly interaction ls --type all --company-id 123 \
--days 90 --max-results 50 --json
xaffinity --readonly interaction ls --type email --person-id 456 \
--after 2025-01-01 --before 2025-12-31 --max-results 100 --json
WARNING: Without --days or --after, the CLI fetches ALL interactions since 2010. Multi-year ranges are auto-chunked into 365-day API calls. --days 3650 = ~10 API calls per type. Always use --days or --max-results to bound the query.
Creating Interactions
Interactions require both internal AND external person IDs:
- Internal: A workspace user (team member). Find yours with
xaffinity whoami.
- External: A contact (non-team-member person in your CRM).
xaffinity interaction create --type meeting \
--person-id EXTERNAL_CONTACT_ID --include-me \
--content "Discussed partnership" --date 2025-06-15T14:00:00Z --json
xaffinity interaction create --type email \
--person-id YOUR_PERSON_ID --person-id CONTACT_ID \
--content "Follow-up email" --date 2025-06-15T14:00:00Z --json
Common error: Forgetting to include an internal person ID causes a validation error. Use --include-me to avoid this.
Expand/Include (N+1 Warning)
--expand on list export triggers one additional API call per record. Use --max-results to control cost.
xaffinity --readonly list export "Pipeline" --expand persons --max-results 20 --json
xaffinity --readonly list export "Pipeline" --expand persons --expand companies \
--max-results 20 --json
Practical limits: <=100 records is safe. 200 records ~5 min. 400+ records may hit timeouts.
Query Command (Advanced)
For complex data retrieval beyond simple ls / list export, use xaffinity query:
- Aggregation & groupBy (count, sum, avg by field)
- Cross-entity filtering (find persons based on their companies)
- Nested boolean logic (AND/OR/NOT)
- Dry-run mode to preview API cost
| Need | Use |
|---|
| Simple search by name/email | person ls --query or person get email:... |
| Export list entries | list export "ListName" |
| Server-side filtered list | list export --saved-view "ViewName" |
| Aggregate/group data | query |
| Filter by related entities | query |
| Preview API cost first | query --dry-run |
Always --dry-run first for queries with include/expand/quantifiers.
xaffinity --readonly query --dry-run --file query.json --json
xaffinity --readonly query --query '{"from": "persons", "where": {"path": "email", "op": "contains", "value": "@acme.com"}, "limit": 20}' --json
For full query reference (JSON structure, operators, aggregation, quantifiers, examples): see references/query-guide.md
Filtering
Entity commands (person ls, company ls): use --query, NOT --filter
xaffinity --readonly person ls --query "@acme.com" --max-results 20 --json
xaffinity --readonly company ls --query "Acme" --max-results 20 --json
xaffinity --readonly list export "All Contacts" --filter 'Department = "Sales"' --max-results 20 --json
--filter is NOT supported on company ls / person ls / query companies|persons|opportunities
Global-entity list endpoints don't support server-side filtering. These commands raise unsupported_filter (exit 2) if --filter is passed. Use:
--query TERM for name/domain/email fuzzy search on global entities.
list export <LIST> --filter ... for list-specific field filters.
company create / person create refuses duplicates by default
Since CLI 1.12.0, create refuses if an exact name/domain (companies) or email/full-name (persons) match exists:
- Exit code: 6
- Error type:
duplicate_exists
- Payload:
error.details.existing.companyId (or personId) — use this ID instead of creating a duplicate.
- For companies,
error.details.existing.isGlobal == true indicates a global Affinity directory record — the hint points to list entry add --company-id <id> instead of creating a tenant-scoped copy.
- Pass
--allow-duplicate to force-create when you genuinely want a distinct record with the same name.
List export: --filter is CLIENT-SIDE (fetches everything first)
xaffinity --readonly list export "Pipeline" --filter 'Status = "Active"' --all --json
xaffinity --readonly list export "Pipeline" --saved-view "Active Deals" --max-results 50 --json
For large lists (1000+ entries), prefer --saved-view over --filter.
Filter operators
= exact match 'Status = "Active"'
!= not equal 'Status != "Closed"'
=~ contains 'Email =~ "@acme"'
=^ starts with 'Name =^ "John"'
=$ ends with 'Domain =$ ".com"'
> greater than 'Revenue > "1000000"'
< less than
>= greater or equal
<= less or equal
& AND 'Status = "Active" & Region = "US"'
| OR 'Status = "New" | Status = "Pending"'
Smart Fields ("Last Meeting", "Next Meeting")
These are UI-only and not in the API. Use --with-interaction-dates on get commands (not ls):
xaffinity --readonly person get email:alice@example.com --with-interaction-dates --json
xaffinity --readonly company get domain:acme.com --with-interaction-dates --json
Gotchas & Workarounds
Internal meetings NOT in interactions
The interactions API only shows meetings with external contacts.
xaffinity --readonly note ls --person-id 123 --max-results 20 --json
Cannot associate interactions with companies/opportunities
The UI's "Also add to... search for an entity" feature has no API equivalent. The API only supports adding person IDs as participants — you cannot directly link an interaction to a company or opportunity.
--query and --filter are mutually exclusive
Use --query for fuzzy text search or --filter for structured filtering. Cannot combine both.
Opportunities are bound to one list
Cannot search opportunities globally. Access them via list export on their specific list.
"Current Organization" is read-only via API
This is a derived/system-managed field driven by enrichment data and email domain — it cannot be set or updated directly via the API. "Current Job Title" can be updated after person creation using field update. Neither field can be set during person create.
Enriched field writes
Most enriched fields ("Phone Number", "Source of Introduction", "Industry", "Location", "Description", etc.) are writable. person field --set / company field --set / field update accept the field name or its field ID.
On companies, some names are ambiguous because the same concept exists under multiple enrichment providers (e.g. "Industry" exists for both the built-in enricher and Dealroom). The CLI raises AmbiguousFieldError with a table of candidate field IDs — copy one into the command to disambiguate.
Derived-only enriched fields like "Current Organization" raise EnrichedFieldNotWritableError (exit code 2) with a clear message instead of silently no-op'ing.
Global organizations are read-only
Companies with global: true cannot be modified.
Progress output goes to stderr
When piping JSON output through another program, progress messages appear on stderr (not stdout). JSON on stdout is clean. If you need to suppress progress: use --quiet or -q.
File Commands
Files can be listed, downloaded, read, and uploaded for companies, persons, and opportunities. These are nested subcommands under <entity> files:
xaffinity --readonly company files ls "domain:acme.com" --max-results 20 --json
xaffinity --readonly person files ls "email:alice@example.com" --max-results 20 --json
xaffinity --readonly opportunity files ls 12345 --max-results 20 --json
xaffinity --readonly company files download "domain:acme.com" --output-dir ./downloads
xaffinity --readonly company files read "domain:acme.com" --file-id 67890 --json
xaffinity company files upload "domain:acme.com" ./document.pdf
Quick Reference
| Task | Command |
|---|
| Find person by email | person get email:user@example.com --json |
| Find company by domain | company get domain:acme.com --json |
| Search people | person ls --query "name" --max-results 10 --json |
| Recent interactions | interaction ls --type all --company-id ID --days 90 --max-results 50 --json |
| Export list (bounded) | list export "ListName" --max-results 100 --json |
| Export list (full CSV) | list export "ListName" --all --csv --csv-bom > out.csv |
| List with server filter | list export "ListName" --saved-view "ViewName" --max-results 50 --json |
| List entity files | company files ls "domain:acme.com" --max-results 20 --json |
| Download entity files | company files download "domain:acme.com" --output-dir ./downloads |
| Aggregate/group data | query --dry-run --file query.json --json (preview cost first) |
| Get command help | xaffinity <command> --help (USE THIS — don't guess flags) |
Remember: Prefix all commands with xaffinity --readonly (and --dotenv if check-key says so).
Installation
pip install "affinity-sdk[cli]"
Documentation