Skip to content

Changelog

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[2.0.7] - 2026-11-03

Exception to semantic versioning (as 2.0.2–2.0.6): two fixes change which rows a filter returns. Each is marked Behavior change.

Highlights

A repeated --filter now combines its conditions instead of keeping only the last one, and a person or company field filtered by id (Owner = 26229794) matches that person instead of nothing.

Changed

  • Behavior change (correctness fix). CLI: repeated --filter values are ANDed on list export, company ls and person ls, and so are repeated list export --expand-filter values. Only the last one used to apply, and the others were dropped without a warning, so such runs returned rows that didn't match the earlier conditions: re-run them. --filter A --filter B is (A) & (B); for OR, use | inside one value. Each repeated value must be a complete expression on its own. (The MCP server refused a repeated --filter, so MCP calls weren't affected.)
  • Behavior change (correctness fix). CLI and SDK: a person or company field compared with a number matches the record's id. Owner = 26229794 (and in, has_any, has_all) in --filter, query and affinity.filters compared the name with "26229794" and matched nothing, without a warning (!= matched every row). It now matches that person's id, and names still match as before. Other record references, such as lists, match their id too. Re-run filters that used ids. A reference with no name (a person without a first and last name, a company with an empty name) still can't be matched by id.

[2.0.6] - 2026-10-14

Fixed

  • FieldResolver.get, get_many and get_by_id accept a plain ListEntry (as returned by companies.get_list_entries() and persons.get_list_entries()) in their type hints. They already worked on it at runtime; strict type checkers no longer need a cast.

[2.0.5] - 2026-10-14

Exception to semantic versioning (as 2.0.2–2.0.4): two fixes change what a call returns or what the CLI prints. Each is marked Behavior change.

Changed

  • Behavior change. CLI: deleted dropdown options in field history and field history-bulk (--json). Affinity sends an option deleted since as its bare text ("Reach Out") and a live one as {id, text, rank, color}; the CLI passed both through, so one field's history mixed strings and objects. A deleted option is now {"id": null, "text": "Reach Out", "deleted": true}, so every dropdown value has a text. Text fields keep their string values; live options are unchanged. When a value is plain text the CLI reads the field's metadata (one call) to tell a dropdown from a text field (history-bulk --list-id already has it). Table and CSV output show the text, as before.
  • Behavior change (correctness fix). SDK: on_error="skip" leaves failed records out of AsyncReminderService.list_batch and AsyncFileService.list_batch. A failed request used to leave its ids in the result with empty lists, which looked like "no reminders" / "no files"; one failed reminders request (50 ids) silently hid them all. A failure on a later page left a partial list that looked complete. They are now absent from the result, as with every other on_error="skip" method: a requested id with no key failed. If you stored or cached results of these calls made with on_error="skip" before 2.0.5, some empty or partial lists may be failures: re-fetch them.
  • SDK: field_value_changes.iter_all() reads 10,000 changes per call by default (was 100), the V1 maximum: a 16,000-change history takes 3 calls instead of 161 (paging ends on an empty page). Pass page_size for smaller pages.
  • SDK: AsyncCompanyService.batch_get / AsyncPersonService.batch_get with with_interaction_dates and field_ids / field_types read the fields in one call per 100 ids and the dates in one V1 call per record: about N + N/100 calls instead of 2N. Results are the same. Under on_error="skip" a failed fields call falls back to one read per id of it.

Added

  • CLI: list export --filter, company ls --filter and person ls --filter report how the filter was split in meta.serverFilter (--json): pushed (conditions Affinity's search applied), local (checked only locally, with reasons) and, on list export when the search was refused or turned off, fallback (why: refused, the list's fields unreadable, or XAFFINITY_QUERY_SERVER_FILTER=0; the whole filter then ran locally). list export --dry-run shows it as data.serverFilter. A --cursor resume doesn't report it. Like query --include-meta, but always present with --json.
  • SDK: persons.get_list_entries() takes limit and cursor (it returned only the first page).

Fixed

  • SDK: companies.get_list_entries() and persons.get_list_entries() set each entry's entity_id and entity_type (ORGANIZATION / PERSON); the endpoint doesn't send them, so they were None. entity stays None there. A cursor from another company's or person's entries raises ValueError instead of labelling its entries with the wrong record. CLI: company get --expand list-entries and person get --expand list-entries (--json) now show each entry's entityId and entityType (1 company, 0 person).
  • SDK: the ListEntry docstring said it always contains the entity; it says where entity is filled.
  • Docs: filtered-run cursors start with xsearch2: (the CLI page, filtering guide, CLI skill and MCP data model said xsearch1:). The field changes docs say a deleted dropdown option is {"referenceType": "deleted-entity", "displayValue": ...}. The companies and persons pages say what reading interaction dates for many records costs, and the companies page and batch_get docstring point to the relationship-intelligence fields for last / next meeting dates.

[2.0.4] - 2026-10-14

Exception to semantic versioning (the maintainer's call, as 2.0.2 and 2.0.3): which API key the CLI uses changes when a key flag and a key environment variable are both set. The ### Changed entry says how. Some fixes below also change what a call returns or raises, or a CLI exit code; each is marked Behavior change.

Changed

  • CLI: --api-key-file and --api-key-stdin win over the environment. A key named on the command line is used even when AFFINITY_API_KEY, AFFINITY_API_KEY_FILE or AFFINITY_API_KEY_COMMAND is set, and the CLI warns that those are ignored (the file is not read and the command is not run). Before, an environment key silently replaced the flag's key, so a stale export could send reads and writes to another workspace. To keep using the environment key, drop the flag. The SDK and the MCP server (which passes no key flag) are unchanged.

Fixed

  • config check-key reports source: "flag" (and a pattern repeating the flag) when --api-key-file or --api-key-stdin is given, as commands now use that key first. It doesn't read stdin; a missing or empty key file counts as not configured.
  • The missing-API-key error no longer says config setup-key saves to the system keychain: it writes a config file, or a .env file with --scope project.
  • Docs: one API-key resolution order across the README, guides, CLI page, MCP README and CLI skill (there is no --api-key CLI flag).
  • Docs: the README coverage table, routing guide, glossary and errors guide describe 2.0's routing (company and person field values, notes.get, transcripts, teams, semantic search and connections are V2); api-versions.md lists every call that sends a minimum version; beta endpoints need no opt-in.
  • Docs: the CLI command page documents files ls/read/download on persons, companies and opportunities (not the old files dump), relationship-strength ls (not get), opportunity field, list entry get, file-url and config update-check; the CLI page says which list commands return a bare data array.
  • Docs: the official-MCP comparison lists this project's transcript, meeting, team, user, duplicate and connection reads; VERSIONING.md describes 2.x and its patch-release exceptions; the MCP README asks for Python 3.10+.
  • Docs: the docs home page introduces the project and links configuration, migration, API versions, the query command and webhooks; a Transcripts reference page; the reference nav no longer files V2 services under "V1 endpoints"; the Affinity/AsyncAffinity docstrings and the sync/async table list every service; the field-values guide starts with the V2 company/person/list-entry methods; the plugins page covers all three plugins.
  • Docs and file-url help: examples use placeholder ids and names.
  • Behavior change. SDK: an empty id list returns an empty page. list(ids=[]) on companies, persons, opportunities, webhooks, teams, users and reminders used to omit the filter and return the whole collection; it now returns no rows without calling the API. limit=0 (or less) raises ValueError where it used to be dropped silently (companies, persons, opportunities, a company's lists and list entries, saved-view entries).
  • Behavior change. SDK: field writes. A bool value raises TypeError (Affinity has no boolean field type; it was sent as malformed text). Datetimes are sent as whole-second UTC with Z, like every other write; a naive datetime is taken as UTC.
  • Behavior change. SDK: delete results. A delete whose response has no success key (an empty body) returns True; only an explicit "success": false returns False.
  • SDK: get_all_pages raises AffinityError when it stops at max_pages with pages left, instead of returning (and caching) a cut-off result.
  • SDK: company and person merges raise AffinityError when the response has no taskUrl, instead of returning an empty URL.
  • SDK: the sync client rejects an async hook with ConfigurationError at construction; under hook_error_policy="swallow" it was logged and never ran.
  • SDK: followed pagination URLs must match the exact host and port of the V1 or V2 base URL.
  • SDK: the response cache key includes the per-call API version sent.
  • SDK: tasks.wait no longer sleeps past its timeout.
  • SDK: lists.resolve no longer remembers a name it didn't find, so a list created later is found.
  • SDK: lists.get_size is shared by every list service on a client and dropped when an entry is added or removed, an opportunity is created or deleted, or a person or company is deleted.
  • SDK: paging stops when a cursor repeats, when a page says more follow but gives no cursor, or after 1,000 empty pages in a row (with a RuntimeWarning), instead of looping. This covers the page iterators (iter(), all()) and the pages() generators of companies, persons, opportunities, lists, saved views and list entries (filtered reads included), sync and async.
  • SDK: association paging tolerates "pagination": null.
  • SDK filters: in, has_any and has_all compare values the way = does, so score in [5, 6] matches the number 5.
  • SDK models: FieldId(True), FieldValueType(True) and fractional floats for open enums raise ValueError; a fractional float no longer replaces a known enum member for the rest of the process. FieldValues.get accepts bare-digit ids (123, "123"). Person and opportunity company ids also read companyIds and current_organization_ids; Location reads streetAddress; V2 list entries pick the entity model from type.
  • CLI query: resuming a cut-off result no longer skips rows. The resume cursor jumped to the next API page, losing the rest of the current page and every fetched row that didn't fit in --max-output-bytes. A cut-off result, with its included records, is now cached and the cursor resumes from it without API calls; each page's included holds only the records related to that page's rows, also when select leaves out id (TOON pages hold as many rows as fit, with matching counts; related records that can't be matched to rows are cut to the budget and counted in includedOmitted). A cursor needs the same --max-records as the first run; a cursor from an earlier version is refused as not matching the query (cursors expire after an hour).
  • Behavior change. CLI query: errors are no longer read as "no data". A rate-limit, server or auth error while fetching related records (for include, relationship filters, or an id lookup, or include: interactions), an expansion (interactionDates, unreplied, person names) or a list's field definitions (when the query uses a list field) fails the query. Before, the record silently had no related records, an id-only stub or an empty expansion, so none, all and _count eq 0 filters could match wrongly. Only a deleted or merged record counts as empty.
  • Behavior change. CLI query: --max-records. When results are cut at it, a warning says so and meta.truncated is set, on every path (before, the cut was silent); with orderBy or an aggregate the cut comes after sorting and aggregating (also when the query has no where), so counts, sums, groups and top-N use every match. Before, they were computed on the first 10,000.
  • Behavior change. CLI query: Ctrl+C exits 130 (exit 0 before). Rows that already went through the query's filter, sort and aggregate are printed; when one of those hadn't run yet, no rows are printed (before, unfiltered rows were) and the error type is interrupted. On Python 3.10, Ctrl+C prints no rows (it still exits 130); Python 3.11+ prints them.
  • Behavior change. CLI query: a listName that matches more than one list, or none, is a validation error (exit 2) asking for the list id; before, the last list with that name was used. An include's list: name that matches several lists fails (exit 2, ambiguous_resolution) and one that matches none is not found (exit 4); before, the scope was dropped. min/max over objects, lists, or a mix of numbers and text is a validation error instead of a crash or a silent pick.
  • CLI query: with orderBy or limit, include (and an expand the sort doesn't read) runs on the rows kept after sorting, limiting and --max-records, not on every match, so a top-N query makes one call per kept row.
  • CLI query: an Affinity error exits with the CLI's documented code (3 auth, 4 not found, 5 rate limit or server error) and its usual error envelope, instead of exit 1 with "Query execution failed".
  • CLI query: CSV output with --max-output-bytes cuts on whole records (a note with a line break could be split), has every row's columns, honors --csv-bom and can be resumed; JSONL resume no longer skips rows whose data contains "truncated". A row larger than --max-output-bytes is printed whole (JSON too, which exited 1 partway through paging) instead of giving an empty page whose cursor repeats.
  • CLI query: include options are applied: list, where, limit and days on a listEntries include, and limit and days on an interactions include. Before, they were ignored, so related opportunities came from every list and interactions used the default 90-day window and 100-record limit.
  • CLI query: a relationship filter on interactions (interactions._count, exists/all/none) no longer hangs when the query has at least as many records as the concurrency limit (10).
  • CLI query: when fetching related records for a listEntries include fails, the other fetches are stopped instead of continuing to call the API and printing "Task exception was never retrieved".
  • CLI query --output toon: a cut-off page, its cursor: line included, stays within --max-output-bytes (if the cursor alone, about 500 bytes, doesn't fit, one row is shown).
  • Behavior change. CLI query --output csv: without --max-output-bytes, execution warnings (the --max-records cut, multi-value fields cut off) go to stderr as with the other formats, and the CSV has the same shape as with a budget: columns in select order (first-seen order without select; they were sorted alphabetically), selected fields.* and expanded interactionDates flattened into columns, and every record ending with CRLF.
  • CLI query: groupBy on object values (such as a location) works; formula fields filter and sort as numbers.
  • Behavior change. CLI: a delete Affinity reports as failed exits 1 (api_error) instead of printing "success": false with exit 0.
  • Behavior change. CLI field history-bulk: when every entry fails it exits with the usual code for that error (3 auth, 4 not found, 5 rate limit or server; 1 for mixed errors), and 3 on any auth error (all exited 0). Partial failures still exit 0 with warnings and now set meta.truncated with truncationReason: "partialFailure".
  • Behavior change. CLI list export expansions: a failed interaction, opportunity or unreplied-message lookup fails the export under the default --expand-on-error raise (it was left empty silently), and warns under skip, keeping the entry's other expansions. An auth error during any expansion fails the export; a deleted opportunity is left out. With --csv these warnings, and the export's other warnings (such as "Filtering locally"), are printed to stderr; they were dropped.
  • Behavior change. CLI --page-size below 1 is a usage error (exit 2) on company, person, opportunity, list, note, interaction and reminder ls, list export and files download. With the SDK's new limit check it was an internal_error (exit 1) on most of them, and note ls and interaction ls accepted it. --help --json (and the MCP command registry) reports such range-checked options, including search --max-results and files ls --max-results, as int; they were string.
  • Behavior change. CLI field writes: true/false anywhere in a --set-json value (including list items and object values) is refused before any write (exit 2, validation_error); Affinity has no boolean field type. A text field no longer stores true as "true".
  • CLI list entry field --unset-value exits 1 when Affinity reports the delete failed, instead of reporting the value as deleted. When an --unset-value or --append step fails after something was already changed, the error is partial_write naming every field written and value removed; a step that timed out or lost the connection is named as "may or may not have been applied" (details.uncertain), and alone it is a network_error that says re-running is safe; field delete clears its caches only after a confirmed delete.
  • CLI: answering "n" to a confirmation under --json prints an error envelope (type aborted), still exit 1, instead of an empty stdout.
  • CLI list ls --type: warns when the first page has no list of that type but more pages exist.

Plugins

  • CLI plugin: 2.0.2 → 2.0.3 — skill: the API-key resolution order (key flags first) and where config setup-key stores the key. Installs pin the CLI version the plugin was written for (XAFFINITY_CLI_SPEC overrides it); the Read hook also guards the CLI's config.toml; the skill names the partialFailure truncation reason; the file download example uses --out (there is no --output-dir).
  • SDK plugin: 2.0.3 → 2.0.4 — skill: the duplicate-company example uses a placeholder name.

[2.0.3] - 2026-10-13

Exception to semantic versioning (the maintainer's call, as 2.0.2): some results change (list(filter=...) pages, the local sort's order, date comparisons in filters). The ### Changed entries say how.

Changed

  • SDK list-entry filters go through Affinity's search: lists.entries(id).list/pages/all/iter/get_first(filter=...) send the part of the filter Affinity's search can answer and check the whole filter locally, as list export --filter does. Only when no part can go there (or the search is refused) is every entry read; that case emits the new ClientSideFilterWarning (a UserWarning).
  • list(filter=...) returns the matches of one page read, possibly fewer than limit (or none), with a next_cursor that carries the filter: follow it, or use iter() for every match. Before, it filtered only the first page and had no way to continue.
  • Filtered-read cursors are xsearch2: (the filter travels as data, so multi-word field names and dates round-trip) in the SDK, list export, and company ls / person ls; 2.0.2's xsearch1: cursors still work.
  • Field values can be named by id in text filters (F.field("field-123"), --filter 'field-123 = …'), as query allows fields.<id>.
  • query's local sort follows Affinity's order (when Affinity can't sort, e.g. an unsortable field or a refused search): ranked dropdowns sort by rank (not by text), multi-value fields by their first item, and text case-insensitively ("apple" before "Zeta"); empty values stay last in both directions. Fields named only in orderBy are now fetched (a local sort used to see them empty). Grouped rows (groupBy) sort by their keys as before.
  • Quantifier queries (all/none/exists) on companies and persons no longer always need --max-records: their where must test a field, which Affinity's search applies first; when it matches at most 100 records the query runs, otherwise it stops and names the count. --dry-run gives a number of API calls instead of "UNBOUNDED" (with --max-records too), which also fixes the MCP query tool's timeout for these queries (it fell back to 120 s). Validation errors found while the query runs exit 2 with validation_error, like those found while planning.

Added

  • server_filter=False on the list-entry list/pages/all/iter/get_first reads every entry and filters locally (to compare or debug), and affinity.ClientSideFilterWarning.
  • More filters go to Affinity's search (verified live): starts_word on single-company fields, and is_not_null / is_null on location fields.

  • A team's members and accessible lists, every page (V2, beta; API 2026-09-17+): teams.list|iter_members(team_id) and teams.list|iter_accessible_lists(team_id); CLI team members <id> and team lists <id> (team get shows only the first 10).

Fixed

  • Dates compare as dates in >, >=, <, <= and between, everywhere filters run (query, --filter, --expand-filter, company ls / person ls --filter, and the SDK's affinity.filters): relative dates (-30d, today) and ISO dates are compared with the field's timestamp in UTC. Before, the text filter compared them as text, so Close > "-30d" matched every dated entry. Equality stays textual (= "today" matches the word). Timestamps with Z or long fractions parse on Python 3.10 too.
  • list export --field X --filter "Y = …" returned no rows: the filter's fields are now fetched even when --field leaves them out of the output.
  • Team reads updatedAt (updated_at), and accepts the membersPreview / accessibleListsPreview blocks under their own names too.

[2.0.2] - 2026-10-13

Changed (breaking)

Exception to semantic versioning: these change what some filters return, in a patch release (the maintainer's call). Filters now mean what Affinity's own search means, so the same filter gives the same answer whether Affinity or the CLI applies it. This reaches Python code too: affinity.filters (F, filter strings, lists.entries(...).list/iter/pages(filter=...)) uses the same comparison rules as the CLI.

  • An empty multi-value field counts as empty. is_null / is empty now match a field with no items ([]), and is_not_null / is not null no longer do, in query, --filter and the SDK's affinity.filters.
  • contains on person and company fields is refused (in query on list entries): use the new starts_word operator, which matches a word in the name that starts with the text, the way Affinity's search does ("Jan" finds "Jane Doe"; "ane" doesn't).
  • query sorts the way Affinity's search sorts: Affinity's order is kept when it sorts, and the local sort breaks ties by id (ascending) instead of keeping fetch order.
  • query: a field name two list fields share is refused. Name the field by id instead: fields.<field id> (e.g. Affinity's and Dealroom's "Year Founded"). Such fields come back keyed by id, instead of one silently overwriting the other. Any field can be named by id.
  • Person and company field values filter by name in --filter and the SDK's affinity.filters (Owner = "Jane Doe", Owner starts_word "Jan"), as they already did in query; before, they were compared as raw objects.

Added

  • starts_word filter operator (query JSON and --filter text: Owner starts_word "Jan").

  • Teams, users and feedback (V2): client.teams.list/iter/get (Affinity API 2026-09-17+; filter by name, sort, includes for the first members and accessible lists), client.users.list/iter/get (internal users; term, status; a user's id is their person id), client.feedback.send(kind, subject, body) (to Affinity's product team). New models Team, TeamMember, UserAccount, UserStatus.

  • interactions.get_meeting(id) (V2, API 2026-07-15+) and notes.list/iter_attached_companies|persons|opportunities(note_id) (every entity a note is attached to, beyond get()'s preview).
  • Duplicate suggestions and warm intros (V2, beta): companies/persons.list|iter_duplicate_suggestions (API 2026-07-15+; term, match_criteria, order_by) and companies.list|iter_coworker_connections(company_id) / list|iter_investor_executive_connections(company_id) (people at a company today whom someone you know may know). CLI company duplicates, person duplicates, company connections.
  • One field at a time (V2): companies/persons.get_field_value(id, field_id) (the value, unwrapped) and update_field_value(id, field_id, value, value_type=None); companies/persons.list|iter_field_value_items(id, field_id) and lists.entries(list_id).list|iter_field_value_items(entry_id, field_id) page through a multi-value field's items (API 2026-09-17+); lists.entries(list_id).list|iter_field_changes( entry_id, ...) give one entry's field change history, oldest first.
  • Search on Affinity's side (V2, beta): companies.find, persons.find and lists.entries(list_id).find (plus iter_find, and find_body for a prebuilt request body) filter, sort and keyword-search on the server, so only matching records are downloaded. Build filters with affinity.criteria: Condition(field_id, operator, value), all_of, any_of, Sort. Value types are read from the fields metadata when omitted, and each operator is checked against the field. Needs the "Export All Organizations directory" permission (companies, persons) or "Export data from Lists" (list entries). Allowed under read-only mode.
  • query on list entries filters on Affinity's side: conditions on list fields that Affinity's search answers (dropdown and ranked-dropdown values, != on dropdowns, numbers, dates, text contains/eq/starts_with, is_not_null on single-value fields) go to the server, so only matching entries are downloaded; the full where still runs locally on what comes back, so results are unchanged. Everything else (related records, entityName, is_null, Affinity's enriched fields, field names two fields share) is filtered locally, as before. If the search is refused (for example, no "Export data from Lists" permission) the query reads the whole list instead, with a warning. --include-meta shows meta.serverFilter (what went to the server, and why the rest didn't); --dry-run names the candidate fields. XAFFINITY_QUERY_SERVER_FILTER=0 turns it off.
  • list export --filter filters on Affinity's side through the same engine as query: the --filter text becomes a query where clause, the part Affinity's search answers is sent, and the whole filter still runs locally. When Affinity applies the whole filter, --all / --max-results / --first-page-only are no longer required (page one is the first page of matches); otherwise the error's details.local says which conditions stay local. The printed cursor (xsearch1:...) carries the search, so --cursor alone resumes a filtered export. A refused search falls back to reading the whole list, with a warning.
  • company ls --filter and person ls --filter work (they used to be refused): the filter goes to Affinity's search, which filters on field values, and the whole filter still runs locally on what comes back. A filter with no field condition is still refused (reading every company isn't practical: use --query). Conditions on Affinity's enriched fields are sent as they are, with a warning that Affinity's search can miss a few records' enriched values. The cursor carries the search (xsearch1:...).
  • query on companies and persons can filter on their fields (fields.<name>): Affinity's search applies what it can, the rest of the where (e.g. name contains) runs on what comes back. A where with no field condition is still refused (it would mean reading Affinity's whole directory). meta.serverFilter with --include-meta; enriched-field conditions carry a warning that a few records' enriched values can be missed.
  • More of query goes to Affinity (list entries too): is_null / is_not_null on every verified field type, multi-value fields included; multi-select dropdowns (eq membership, in, has_any, has_all, contains/starts_word on option text, !=); != on numbers; and starts_word on a person or company field (person, multi-person and multi-company fields; one per query, at the top level of the where, plain text of 3+ letters).
  • query sorts on Affinity's side when every orderBy key is a sortable field (fields.<name>, up to 5, no groupBy/aggregate): with a limit, the fetch stops once enough records pass the filter, instead of reading everything to sort it. This works on companies and persons without a where too. meta.serverFilter.sort says so.
  • CLI: team ls|get, user ls|get, meeting get, note attached, feedback send (asks for confirmation: the text leaves your organization). The MCP registry has them; feedback send goes through the user-confirmation flow.

Fixed

  • query --dry-run no longer claims a "server-side filter" for list entries that was never applied.

[2.0.1] - 2026-10-12

Changed (breaking)

Exception to semantic versioning: these renames break code written against 2.0.0, in a patch release. 2.0.0 had been out for under an hour, and the renames remove method names that said which Affinity API they used instead of what they do.

  • notes.list_v2() / iter_v2() → notes.feed() / iter_feed() (every note across the organization; the CLI's note feed).
  • notes.get_v2() → notes.get(), which now returns NoteV2 (includes=True adds the attached companies, persons and opportunities). The V1 notes.get() (returning Note) is removed; notes.list() (one entity's notes, V1) is unchanged.
  • files.list_v2() / iter_v2() → files.feed() / iter_feed() (every file you can see, newest first).
  • files.get_v2() is removed. files.get_download_url() now asks V2 first: one request for the details and a signed URL (valid 60 seconds) instead of V1's two. It falls back to V1 when the client is pinned to an API version older than 2026-09-17, or when V2 refuses or doesn't find the file; the result (PresignedUrl) is the same either way. files.get() stays on V1 (EntityFile, with the file's person, company or opportunity) and works under any version pin.
  • CLI note get reads the note from V2: same output keys, content is the stored HTML (as in note create/update output), and the attachment ids come from V2.
  • CLI files read and file-url get the file's details and URL in one request where V2 can answer (V1's path otherwise).

Plugins

  • SDK plugin: 2.0.0 → 2.0.1 — skill: the renamed note methods.

[2.0.0] - 2026-10-12

Highlights

SDK 2.0 moves to Affinity's V2 API wherever V2 can do the job. Webhooks, reminders, note writes, list get/create, file reads (new files.get_v2/list_v2) and the rate-limit endpoint now use V2; the CLI gains xaffinity webhook ls|get|create|update|delete. V1 stays only where V2 can't do it, and for person create/delete, opportunity rename/delete and adding to a list, which the V2 spec documents but the API didn't serve when tested. Breaking: webhook and reminder models change shape, note writes return NoteV2, add_opportunity() is removed, and operations added in newer Affinity API versions refuse a client pinned to an older one (ApiVersionTooOldError). See the Migrating to 2.0 guide.

Changed (breaking)

  • Webhooks use the V2 API (/v2/webhooks, Affinity API 2026-07-15 or later, beta). WebhookSubscription is now Webhook: webhook_url → url, disabled → status (WebhookStatus.ACTIVE/DISABLED), created_by → creator (PersonRef), plus created_at/updated_at. WebhookCreate(url=…, subscriptions=[…]) requires subscriptions ([] = every event type). WebhookUpdate takes url, subscriptions, status and sends only what is set. list() returns one page (ids, created/updated ranges, limit, cursor); iter() returns every webhook. update() returns the webhook as read back (two requests); delete() returns None. 1.x field names raise ValidationError instead of being ignored. Without permission to manage all webhooks, the API lists, reads and changes only the caller's own webhooks. A client pinned to API version 2024-01-01 gets ApiVersionTooOldError.
  • Reminders use the V2 API (/v2/reminders; get/delete need API 2026-07-15, update 2026-09-17). Reminder has the V2 shape: creator, owner, completer, person are PersonRef (id only; look names up separately), company/opportunity are CompanyRef/OpportunityRef, recurrence is recurrence (period_days, reset_type; the read-only reset_type/reminder_days properties still work), plus updated_at. ReminderType, ReminderResetType and ReminderStatus are string enums with the V2 values (member names unchanged; the 1.x integers are still accepted); ReminderResetType.MEETING is now an alias of EVENT. list() takes limit (1-100), cursor, ids and created/updated ranges instead of page_size/page_token, and returns reminders soonest-due first. update() sends only the fields that are set (clear_content=True removes the content) and returns the reminder as read back. list_batch() is on the sync client too, and fetches up to 50 entities per request. Kept on V1 where V2 can't do it: changing a reminder's type, and updating or deleting a reminder you didn't create and don't own (V2 refuses; the SDK retries through V1). A reminder must be attached to one person, company or opportunity (both APIs require it; the SDK now says so before sending).
  • Note writes use the V2 API (notes.create, update, delete; reads are unchanged). create() and update() return NoteV2 (content.html, creator; update() reads the note back with its attachments, two requests). V2 stores notes as HTML: plain text (the default type) is escaped and wrapped in paragraphs, and NoteUpdate takes type too (plain text unless set to HTML, whatever the note was before). With type=NoteType.HTML, <b>/<i> become <strong>/<em>, and what the API rejects (style/class attributes, img, pre, code, blockquote, mention spans, non-http(s)/mailto links) raises ValueError naming the allowed tags. A reply (parent_id) with persons/companies/opportunities raises instead of ignoring them; AI_NOTETAKER/EMAIL_DERIVED can't be created. NoteUpdate can replace a note's persons, companies and opportunities ([] clears). Changing the text of a note that @mentions people is refused by the API; the error now says so. NoteV2 and NoteContent are exported from affinity.models.
  • rate_limits.refresh() reads GET /v2/rate-limit (API 2026-07-15+), falling back to response headers as before (now also when the client is pinned to an older API version). RateLimitSnapshot is unchanged; RateLimits also reads the V2 names (callerPerMinute, orgPerMonth).
  • Lists: get() and create() use the V2 API. get() no longer fills fields (use get_fields()) or additional_permissions; it sets created_at. get_size() still reads the size from V1 (V2 has none). create() makes the caller the owner; a public list needs the "Share accessible Lists globally" permission. A list created with owner_id or additional_permissions still goes through V1.
  • entries(list_id).add_opportunity() is removed. Opportunities can't be added to a list as entries: create them on their list with opportunities.create(OpportunityCreate(list_id=...)).
  • Person create/delete, opportunity rename/delete and adding to a list stay on V1: the V2 spec documents them, but Affinity didn't serve them when tested (2026-10-12).

Added

  • files.get_v2(), files.list_v2(), files.iter_v2() (V2, Affinity API 2026-09-17+): a file's metadata with a download URL that expires after 60 seconds, and every file visible to you across the organization, newest first (filter by created date and uploader). FileV2 model. files.get/list/iter stay on V1: only V1 lists one entity's files.
  • CLI webhook ls|get|create|update|delete. create takes --url and --event (repeatable) or --all-events; update takes --url, --event/--all-events, --status active|disabled. create, update and delete ask for confirmation (--yes in scripts), since a webhook sends CRM events to an outside URL. Rows include allEvents.
  • CLI list get <id> no longer prints .data.list.fields (the fields are in .data.fields as before), additionalPermissions is always [], and createdAt is added; list create --owner-id creates the list through V1.
  • CLI reminder create/update --reminder-days help now says what it is: the days between a recurring reminder's due dates.
  • CLI note update --type plain-text|html; note create --type is now plain-text or html (Affinity creates the other kinds itself). note create/update output keeps the note get keys (type is html; content is the stored HTML). The duplicate-note warning still finds an earlier copy: V1 shows notes stored as HTML in a markdown form, which the check allows for.
  • CLI reminder update --clear-content; reminder ls --page-size is now at most 100, and rows include updatedAt; --reset-type event (meeting still accepted, printed as event).
  • MCP: the webhook commands are in the command registry; webhook create/update/delete go through the same user confirmation as deletes, and the confirmation says why (events sent to an outside URL).

Fixed

  • Person.company_ids and Opportunity.company_ids are now read from V1 responses (organization_ids), e.g. from persons.create() with companies, persons.update(), opportunities.update() and get_details(); they were always empty.

Docs

  • New API coverage reference page: every Affinity API operation (V2 and V1) and how the SDK supports it — the SDK call, an equivalent operation, or the planned release — with beta operations and the minimum API version marked.

Internal

  • tools/api_coverage.py and the ledger tools/api_coverage.json (CI: --check): every operation in the pinned V2 snapshots and the new V1 snapshot (tools/api_coverage_v1_snapshot.json, 59 operations) has exactly one row. tests/test_api_coverage.py runs each supported row's call, sync and async, against a recording HTTP layer and checks the method, path and API version it requests.

Plugins

  • SDK plugin: 1.7.1 → 2.0.0 (matches SDK 2.0) — skill: note writes go through V2 (HTML, the allowed tags), and add_opportunity() is gone (create the opportunity on its list).
  • CLI plugin: 1.13.6 → 2.0.0 (matches CLI 2.0; no content change).
  • MCP: 1.32.2 → 2.0.0 (requires CLI 2.0.0; see mcp/CHANGELOG.md).
  • CLI plugin: 1.13.5 → 1.13.6 — skill: the Cowork install line adds --no-warn-script-location (pip's "not on PATH" warning was noise; the skill adds the folder to PATH in each call), and the Cowork key paths (--env-file, AFFINITY_API_KEY_FILE) are quoted, so connected folders with spaces in their names work.
  • CLI plugin: 1.13.4 → 1.13.5 — no clis credential declaration. In local Cowork it routed every xaffinity call through Desktop's CLI credential broker, which isn't enabled yet and refuses the call ("cli-wrapper: plugin oauth disabled"), so the CLI never ran whatever key the VM had. Cowork's plugin settings no longer show an Affinity API Key field. The VM has no xaffinity of its own, so the skill now installs it when it's missing (pip install --user, about 10s, once per task) and puts ~/.local/bin on PATH in each call; the CLI then reads the key as the skill describes (a connected folder's .env with --env-file, or a key file).
  • CLI plugin: 1.13.3 → 1.13.4 — in local Cowork the plugin's hooks run on the host Mac, not in the VM where the CLI runs, so the key check saw the Mac's key (or none) and could deny every data command even with a key set in Cowork. There the hooks now do nothing; a missing key shows up as the CLI's own error. Cloud Cowork and Claude Code keep the check. The .env read guard's message no longer claims the key is in the environment.

[1.29.0] - 2026-10-11

Highlights

field history-bulk works on global fields. A global field's changes (a company or person field shown on a list) belong to the company or person, not to the list entry, so reading them per entry returned nothing, with no warning. They are now read per company/person, or field-wide when the field's history is small, and each row carries the company's entry on the list.

Fixed

  • field history-bulk on a global field returned no rows. It now reads per company or person (one call each; entries sharing one are read once, with a warning) and fills listEntryId with the entry on the list. With --all on 100+ of them it reads the field's org-wide history instead when that fits in one call; a forced --strategy field reads up to 200,000 changes and otherwise stops with an error. Rows include changes from before the company joined the list.
  • field history-bulk with a field that isn't on the list, or an enriched field, gave empty results; both are usage errors now. So is a global field with --list-entry-ids, which can't map entries to companies.
  • field history --list-entry-id on a global field returned nothing; it now says the field is global and to use --company-id / --person-id.
  • config check-key: when the key comes from a .env outside the current directory (e.g. --env-file with a Cowork connected folder, where each shell call starts above it), the suggested pattern names it with --env-file <path>, so later commands find it too.

Changed

  • field history-bulk reads field-wide 10,000 changes per call (was 500): fewer calls for list fields too. The dry run reports fieldKind (list / global) and, for a global field, entities.

Plugins

  • CLI plugin: 1.13.2 → 1.13.3 — skill: history-bulk on global fields; Cowork keys in a connected folder need --env-file /sessions/<id>/mnt/<folder>/.env (each shell call starts in the session root), and the check-key pattern carries it.

[1.28.0] - 2026-10-11

Highlights

Read the API key from 1Password Environments. AFFINITY_API_KEY_FILE (and --api-key-file) accept a .env file and a named pipe, so an Environment's locally mounted .env file works directly: the CLI waits for 1Password to supply the key after you approve the read, and the key never touches the disk.

Added

  • AFFINITY_API_KEY_FILE / --api-key-file (SDK from_env() too): a .env-style file is read for its AFFINITY_API_KEY= line (export and quotes allowed); a file without that line is an error. A named pipe is read when its writer supplies the content, waiting up to 30 seconds, with a message naming 1Password if nothing arrives. config check-key reports a named pipe as configured without reading it, and skips its API-version probe for it (reading would prompt). Tested with a real 1Password Environments locally mounted .env file: approval and unlock prompts, repeated and concurrent reads.

Changed

  • field history-bulk --strategy field never falls back to one call per entry: if the field-wide read (or reading the list's fields) fails, the command fails with a hint to retry or use --strategy entries. --strategy auto still falls back, with a warning.

Plugins

  • CLI plugin: 1.12.0 → 1.13.0 — skill: field history without a selector, field changes, history-bulk --strategy; interaction feed, note feed, note replies; transcripts; company|person relationships, merge history, task ls; field options.
  • CLI plugin: 1.13.0 → 1.13.1 — skill describes the current CLI only (no per-version notes); one upgrade hint at install.
  • CLI plugin: 1.13.1 → 1.13.2 — skill: AFFINITY_API_KEY_FILE reads .env files and 1Password Environments locally mounted .env files; forced --strategy field never falls back.
  • SDK plugin: 1.7.0 → 1.7.1 — skill: API version pinning, note/file/semantic company search, hidden values; new references/services.md organised by question (field history, org-wide activity, notes V2 and replies, transcripts, relationships, merges, dropdown options, uploads).

[1.27.0] - 2026-10-10

Highlights

field history-bulk --all on a list field: a few calls instead of one per entry. For a field that belongs to the list, on lists of 100 or more entries, the history is now read field-wide (one call per 500 changes) and filtered to the list's entries: the same rows as before. A 9,000-entry pipeline no longer needs 9,000 calls.

Added

  • field history-bulk --strategy auto|field|entries (default auto). Dry run reports strategy and, for field, estimatedApiCallsNote; estimatedApiCalls stays the per-entry worst case.

Changed

  • field history-bulk rows are sorted by changedAt (they came in no particular order). Changes on entries no longer on the list are counted in a warning when read field-wide. If the field-wide read fails before any data arrives, it falls back to per-entry calls with a warning; a failure partway through is an error that suggests --strategy entries.

[1.26.0] - 2026-10-10

Highlights

Notes as V2, alongside V1. List notes across the organization with their creator, HTML content, mentions, meeting and transcript links, reply counts and attached records (note feed), and read a note's replies (note replies). note ls / note get and the V1 Note model are unchanged.

Added

  • SDK notes.list_v2() / iter_v2() / get_v2() (filters: creator, created and updated times; includes=True adds reply counts and attached company/person/opportunity previews) and notes.list_replies() / iter_replies(), returning NoteV2.
  • SDK persons / companies / opportunities: list_notes() / iter_notes() (V2). Company notes send Affinity API version 2026-07-15 (beta before); Affinity may refuse opportunity notes (AuthorizationError).
  • NoteV2: replies_count and companies / persons / opportunities (+ *_total), filled only with includes; creator may be None.
  • CLI note feed and note replies.

[1.25.0] - 2026-10-10

Highlights

Org-wide activity, merge history and relationships. List emails, meetings, calls and chat messages across the organization (interaction feed), past company and person merges (merge-history, task ls), and who on your team knows people at a company, or a person, and how well (company relationships, person relationships).

Added

  • SDK interactions.list_emails() / list_meetings() / list_calls() / list_chat_messages() and iter_*(): V2 org-wide lists with after / before (sent or start time), created_after and updated_after. Models EmailV2, MeetingV2, CallV2, ChatMessageV2, InteractionAttendee (previews flattened into lists plus totals). Only items the key's user may see. interactions.list() (V1, one entity) is unchanged.
  • SDK companies / persons: list_merges(), iter_merges(), get_merge_state() (models CompanyMergeState, PersonMergeState); tasks.list_merge_tasks("company"|"person") and iter_merge_tasks(). They need the "Manage duplicates" permission and the organization admin role.
  • SDK companies / persons: list_relationships() / iter_relationships() (models Relationship, RelationshipPerson), with min_score, order and total_count. They send Affinity API version 2026-07-15 (beta before that); a client pinned to 2024-01-01 gets ApiVersionTooOldError.
  • CLI interaction feed --type email|meeting|call|chat-message, company|person merge-history ls|get, task ls --kind company-merge|person-merge, company|person relationships.

[1.24.0] - 2026-10-10

Not published separately: these changes ship in 1.25.0.

Highlights

Dropdown options and meeting transcripts. List a dropdown, ranked-dropdown or status field's options and add, rename, recolor or delete them on list fields (field options); read AI Notetaker transcripts with their dialogue (transcript ls, transcript get).

Added

  • SDK lists.get_field_dropdown_options(), companies.get_field_dropdown_options(), persons.get_field_dropdown_options() (every page). with_status_types=True reads with API version 2026-07-15 so status fields report status-dropdown with status_category / win_rate (older versions show them as ranked-dropdown).
  • SDK lists.get_field_dropdown_option(), create_field_dropdown_option(), update_field_dropdown_option() (returns the option read back; the API answers 204) and delete_field_dropdown_option(), checked like the API (types, required rank/color/status category, colors white/gray/blue/green/purple/orange/red, win rate 0-100). They send API version 2026-07-15. Deleting an option also clears it from every list entry that has it.
  • DropdownOption: type, status_category, win_rate (V2; None from V1).
  • SDK client.transcripts: list() / iter() (created-at window, total_count), get(), fragments() / iter_fragments(). Models Transcript, TranscriptFragment. PaginatedResponse.total_count (when the endpoint reports it). NoteV2 gains interaction and transcript_id.
  • CLI field options ls|create|update|delete (delete is destructive: confirmation names the option and the data loss; --yes in scripts) and transcript ls|get.
  • HTTP client: get(), get_all_pages(), get_url(), post() and delete() accept min_api_version; get_all_pages() sends it on every page it follows.

Changed

  • The CLI's dropdown lookups for field writes go through the new SDK methods (same requests, still without a version header, so clients pinned to 2024-01-01 keep working).

[1.23.0] - 2026-10-10

Highlights

Field history across all entities, and every change in the workspace. field history no longer needs an entity: without one it returns a field's changes for every entity, bounded by --changed-after and/or --max-results. The new field changes command (V2) lists changes across all fields and entities, filtered by field, list entry, changer, time window and action: the basis for delta sync and audits.

Added

  • SDK field_value_changes.list(): optional entity selector (at most one) and the new V1 parameters changed_after (inclusive), limit, order_by ("asc"/"desc") and after_id, checked like the API checks them (after_id needs changed_after and order_by="asc").
  • SDK field_value_changes.iter_all(): a field's whole history, oldest first, fetched in pages (keyset paging on changed_at and id).
  • SDK field_value_changes.list_global() / iter_global(): GET /v2/field-value-changes across all entities and fields, with filters for field(s), list entry(ies), changer, time window and action type (add/update/delete). New model FieldValueChangeV2 (with FieldValueChangeField, FieldValueChangeEntity, FieldValueChangeListEntry); value is kept as the API sends it.
  • CLI field changes (V2 org-wide list): --field-id/--list-entry-id (repeatable, OR), --changer-id, --changed-after, --changed-before, --action-type, --order, paging.
  • CLI field history: --changed-after and --order; --max-results is also sent to the API.

Changed

  • field history / field_value_changes.list() accept no entity selector (previously exactly one was required). The CLI then requires --changed-after or --max-results.
  • field_value_changes.iter() passes the new parameters through; its behaviour is otherwise unchanged (one request, the API's default order).
  • --max-results on field history with --order desc (the default) returns the most recent N, as before; it is now also applied by the API.

[1.22.0] - 2026-10-10

Highlights

Merges ask for confirmation, like deletes. person merge and company merge remove the duplicate record and cannot be undone; they are now marked destructive, prompt for confirmation on a terminal, and take --yes to skip the prompt.

Breaking: scripts that run person merge / company merge must now confirm: add --yes (or pipe in y); with no answer the command exits 2 (usage_error) and merges nothing. This is a stated exception to the MAJOR-bump rule (VERSIONING.md): an irreversible command must not run unconfirmed. If you use the MCP server, upgrade it to 1.25.1: MCP 1.25.0 cannot pass --yes to merges, so merges fail (safely) with CLI 1.22.0. This includes the Claude Desktop extension: update it to 1.25.1 or later together with the CLI.

Changed

  • All delete commands and both merges share one confirmation path. The prompt is written to stderr (stdout stays clean for --json). A typed or piped y still confirms and n still aborts (exit 1). With no answer at all (end of input, e.g. a script without --yes) they now fail with a usage error (exit 2) in the normal JSON envelope instead of a bare exit 1, and never call the API.
  • --help --json: person merge and company merge have destructive: true and a --yes parameter.

[1.21.0] - 2026-10-09

Highlights

Company and person field writes are all-or-nothing. company field and person field send every --set, --set-json and --unset of a command in one request that Affinity applies completely or not at all, like list entry field since 1.19.0. Enriched fields (including "Current Organization") and Source of Introduction are written the same way.

Breaking: the JSON output of company field / person field changes: created items are {fieldId, name, value}, cleared fields are listed in a new cleared, and deleted is gone. --get returns V2 values (e.g. a dropdown as {dropdownOptionId, text}). These writes need Affinity API version 2026-07-15 or newer: unpinned, the CLI sends it for the write; pinned to 2024-01-01, the command exits 2 (api_version_error) before sending anything. EnrichedFieldNotWritableError is removed. This is a stated exception to the MAJOR-bump rule (VERSIONING.md), decided for a clean single write path.

Also: list ls --query, faster list lookup by name, and calls that need a minimum API version.

Added

  • Calls that need a minimum Affinity API version: an unpinned client sends that version for the call; a client pinned to an older version raises ApiVersionTooOldError (new, a subclass of UnsupportedApiVersionError) before sending anything. SDK affinity_api_versions_per_operation; CLI meta.affinityApiVersionPerOperation; such versions don't trigger the multi-version warning. Internal HTTPClient.patch(..., min_api_version=...).
  • list ls --query TEXT: lists whose name contains the text (case-insensitive, matched by Affinity). SDK: term= on ListService.list() / pages() (sync and async).
  • SDK: CompanyService / PersonService .get_field_values(id, ids=...) (V2, keyed by field id) and .batch_update_fields(id, updates, value_types=...) (one all-or-nothing PATCH, at most 100 updates; sends API version 2026-07-15), sync and async. Affinity.require_api_version( minimum, operation=...).

Changed

  • Breaking (CLI output): company field / person field write every field of a command in one V2 request (all or nothing) instead of one V1 request per value; output created ({fieldId, name, value}) and cleared, no deleted. A value equal to the current one is not rewritten (read through V2, so enriched fields are compared too). --get reads V2 values, so enriched fields return their value (they returned null before). Writes need API version 2026-07-15+ (see Highlights). More than 100 fields in one command, or the same field twice (by name, other casing or id, or in both --set and --unset), exits 2 with nothing changed.
  • Hints for 401 / 403 errors (and config setup-key on a 403) name the documented causes: a mistyped or revoked key, the key's IP allowlist, in-app sharing and role permissions.
  • A single value for a multi-value text field (e.g. --set Industry Fintech) is sent as a one-item list; numbers given for text fields (--set-json '{"Description": 42}') are sent as text (Affinity rejects both otherwise).
  • ListService.resolve() / resolve_all() (and every CLI command that takes a list name) let Affinity narrow the lists by name first instead of reading every list; the exact, case-insensitive comparison is unchanged, and a miss still checks every list. Non-ASCII names always check every list.
  • MCP registry: note search / file search / company search report their real limits (default 20 / 20 / 100, max 100); files read commands no longer carry a limit config (their --limit is a byte size).

Removed

  • Breaking (SDK): EnrichedFieldNotWritableError — "Current Organization" and the other enriched fields are writable through V2, so nothing raises it any more.

Fixed

  • list entry field / opportunity field (and now company/person): setting a date with a time of day just after midnight UTC (e.g. 2024-04-02T03:00:00Z when April 1 is stored) was skipped as "unchanged". Affinity keeps the UTC date of the value written (verified live), not the Pacific date of that instant.

Plugins

  • SDK plugin: 1.6.1 → 1.7.0 — skill: companies/persons.batch_update_fields() and get_field_values() (one all-or-nothing write, API version 2026-07-15), lists.pages(term=); "Current Organization" is writable (the EnrichedFieldNotWritableError note is gone).
  • CLI plugin: 1.11.0 → 1.12.0 — skill: list ls --query; company field / person field writes are all-or-nothing with created / cleared output and need API version 2026-07-15+; enriched fields (incl. Current Organization) are writable; stale field update references removed.

[1.20.0] - 2026-10-08

Highlights

Breaking: get_fields() no longer takes field_types=. Affinity's fields endpoints never supported it, so it never filtered anything. To keep some field types, filter the result: [f for f in client.companies.get_fields() if f.type == FieldType.GLOBAL].

Removed

  • field_types= from get_fields() on companies, persons and lists (sync and async). The */fields endpoints don't declare fieldTypes in any API version and Affinity dropped it silently, so every field was always returned (noted in 1.19.0). Passing it now raises TypeError. field_types= on the data calls (companies.get / list / iter / all, the person equivalents, list entries) is unchanged. This removal ships in a minor release as a deliberate exception to the MAJOR-bump rule in VERSIONING.md, since the argument never worked.

[1.19.0] - 2026-10-08

Highlights

Field writes are all-or-nothing. list entry field and opportunity field send every --set, --set-json and --unset of a command in one request that Affinity applies completely or not at all: if it rejects one value, no field changes. Clearing a field no longer deletes values one by one.

Search. note search, file search and company search (natural-language, ranked) search the text of notes, the contents of files, and companies by description. They work under --readonly.

All three Affinity API versions. Affinity's V2 API has versions 2024-01-01, 2026-07-15 and 2026-09-17; each API key has a default. Pin one with --api-version / AFFINITY_API_VERSION / Affinity(affinity_api_version=...), see which one answered in meta.affinityApiVersion, and see a key's default with whoami. Values Affinity hides on restricted opportunities (2026-07-15+) are flagged instead of looking empty.

Also: uploads return the new file's id, new field value types, get_fields(filter=, includes=).

Added

  • Pin the Affinity V2 API version: Affinity(affinity_api_version="2026-09-17") / AsyncAffinity / ClientConfig, CLI --api-version, env AFFINITY_API_VERSION, profile api_version (flag > env > profile). Accepts 2024-01-01, 2026-07-15, 2026-09-17, current, or auto/key-default for the API key's default (still the default behaviour). The header is sent on every V2 request including nextUrl pages, never on V1, and is removed on cross-host redirects. Invalid values are rejected before any request; unknown dates warn.
  • UnsupportedApiVersionError when Affinity rejects the pinned version (no silent fallback; CLI error type api_version_error, exit 2). client.affinity_api_versions_seen and client.get_key_default_api_version(); affinity.api_versions module.
  • CLI JSON meta.affinityApiVersion (the version that answered, or a list plus a warning if more than one did); whoami and config check-key report keyDefaultApiVersion. New guide "Affinity API versions".
  • Values Affinity hides (type: "hidden", restricted opportunities, API 2026-07-15+): FieldValues.is_hidden() / hidden_fields(); the CLI warns that they are masked, not empty, wherever it warns about truncated values, and keeps them in "list-only" field output.
  • xaffinity note search PROMPT, xaffinity file search PROMPT (new file group) and xaffinity company search PROMPT. --company-id takes the usual company selector; --note-id / --file-id limit the search to given ids; company search --list filters by company lists; --max-results/--limit/-n is 1–100 (larger values are clamped). JSON data has one row per hit; company search puts Affinity's reading of the prompt in meta.explanation.
  • SDK: client.notes.search(), client.files.search(), client.companies.semantic_search() (sync and async) with models NoteSearchResult, FileSearchResult, SemanticSearchResult, SemanticCompany and the NoteKind enum; HTTPClient.post(..., read_only=True) for read-only POSTs (allowed under WritePolicy.DENY, retried like GET).
  • SDK: files.upload_returning_files(), upload_path_returning_files() and upload_bytes_returning_files() (sync and async) return the created files; they never raise after a successful upload. CLI company/person/opportunity files upload rows include fileId and createdAt.
  • SDK: ListEntryService.batch_update_fields(..., value_types=) (sync and async) sends typed values, multi-value lists and None (clears the field); BatchOperationResponse.operation.
  • FieldValueType.FORMULA_NUMBER, LIST_MULTI, NOTE, REMINDER and FieldType.HIDDEN; table output renders note, reminder and formula-number values.
  • get_fields(filter=, includes=) on companies, persons and lists (sync and async): filter name="X" (exact) or name=~X (substring), case-sensitive, | for OR; includes filterability / sortability.
  • ListSummary.creator_id, ListEntryWithEntity.creator_id.

Changed

  • list entry field / opportunity field output: created lists {fieldId, name, value} per field set (the batch request returns no rows); cleared lists the fields --unset cleared; deleted only counts values removed by --unset-value.
  • list entry field / opportunity field: field value types come from V2 metadata (V1's type 2 means "text or dropdown"); dropdown options are read fresh for every dropdown field written; the same field named twice (name, other casing or id) is refused; writes to types Affinity doesn't accept (interaction, formula-number, list-multi, note, reminder) are refused before any request; a timeout says the outcome is unknown and that re-running is safe; a 403 names restricted field access. opportunity field --set/--json/--unset no longer writes through V1.
  • SDK response-cache keys and CLI session-cache keys include the configured API version. Existing session-cache entries are not reused after upgrading.
  • config check-key makes one best-effort request (5 s, no retries) to report the key's default API version; its exit code is unchanged.
  • field create --value-type offers only types Affinity can create (interaction, filterable-text, filterable-text-multi are no longer accepted); a -multi type implies --allows-multiple.
  • is_public on lists reads public or isPublic; the company and person services reject FieldType.HIDDEN in field_types=. The */fields endpoints ignore field_types (documented).
  • CLI JSON meta may carry explanation (from company search).
  • person get / company get table output uses each list entry's listName instead of fetching every list.

Deprecated

  • expected_v2_version: it never selected a version. Use affinity_api_version and affinity_api_versions_seen.

Fixed

  • EntityFile.company_id was always None for files read through V1 (files.list(), files.get()).
  • company ls / person ls --field-type list was accepted; it now exits 2.
  • field create --value-type <type>-multi without --allows-multiple created a single-value field.
  • list entry field --append on a field the CLI has no information about said it "holds one value"; it now says it can't tell and to use --set.
  • Upload docstrings described the wrong return value.

[1.18.1] - 2026-10-07

Highlights

Dropdown and status fields can be set from company field, person field and opportunity field. Every --set of a dropdown on these commands failed with "Dropdown option '…' not found", including opportunity field --set Status <stage>.

Fixed

  • CLI company field, person field and opportunity field --set on a dropdown, ranked dropdown or status field always exited 2 with "Dropdown option … not found": the field information they load (V2) carries no options. The options are now read fresh from Affinity for the dropdown fields being set (never from a cache).
  • Plain dropdowns on companies and persons are written by option id (V2), so a value can never create a new option on an account-wide field. Through V1, which these commands used, text that no longer matches an option (e.g. renamed moments earlier) silently creates one.
  • Ranked dropdowns and status fields are written by option id; V1 rejects their text.
  • opportunity field --set on a field with no value failed with "list_entry_id is required".
  • list entry field --set on a dropdown option added moments ago: the option is read fresh from Affinity before the value is refused.
  • Re-setting a dropdown to the option it already holds no longer rewrites it.
  • company/person/opportunity field --unset with a misspelled field name now fails before any --set in the same command is written.

[1.18.0] - 2026-10-07

Highlights

CLI field writes no longer lose data. CLI versions through 1.17.0 deleted a field's values before writing the new ones, so any write Affinity rejected left the field empty. Together with a second bug this hit everyday commands: list entry field --set <number field> 5 sent the number as text, Affinity rejected it, and the field ended up blank. Now: list entry field --set replaces the value in one write (Affinity leaves the field untouched if it rejects the write); company field, person field and opportunity field update a value in place and add new values before removing old ones; numbers and locations are checked and sent in the shape Affinity accepts; and --append on a multi-value location field adds instead of replacing the list. Upgrade if anything writes fields through the CLI or the MCP server.

Breaking: --append on a field that holds a single value now exits with a usage error instead of silently replacing the value; use --set.

Multi-value fields show when Affinity cut them off at 100 values (warnings in the CLI, FieldValues.is_truncated() in the SDK), and opportunity field works again.

Added

  • Truncation signal for multi-value fields. Affinity's V2 API returns at most 100 values per multi-value field and reports the real number in totalCount; nothing surfaced it, so a field with 250 values silently looked like it had 100. New FieldValues.total_count(), is_truncated() and truncated_fields(). The CLI warns ("Field 'X' on … shows 100 of 250 values") in company get, person get, opportunity get, list entry get, list entry field --get, list export (JSON warnings, stderr for CSV) and query.
  • query --output json now includes a warnings array when the run produced warnings. Execution warnings were previously dropped in every output format; other formats now print them to stderr.
  • WebhookEvent.SMART_INTERACTION_VALUE_UPDATED (smart_interaction_value.updated), a webhook subscription type the V2 API now lists. Before, the event still parsed, but only as an unnamed UNKNOWN_… member.

Changed

  • Breaking (CLI): list entry field --append on a field that holds a single value exits 2 ("use --set") before anything is written. It used to replace the value silently.
  • CLI list entry field --set on a list entry no longer reports deleted in its JSON output: the old values are replaced by the write itself, not deleted first.
  • SDK: a location / location-multi value passed to update_field_value(..., value_type="location") (or "location-multi") is completed to the five keys Affinity requires (streetAddress, city, state, country, continent; missing ones sent as null; street_address is accepted). Affinity rejected a location missing any of them.
  • ListEntryService.update_field_value() (sync and async) raises ValueError before sending more than 100 values for a company, person, number, text or location multi-value field, which Affinity rejects (dropdown-multi has no such limit). batch_update_fields() raises ValueError for more than 100 updates (the API's limit per request) and for list values, which it used to send as an invalid text value.

Fixed

  • Data loss in CLI field writes. list entry field --set / --set-json deleted every existing value of the field and then wrote the new one, so a write Affinity rejected (an invalid value, more than 100 values, a network error) left the field empty. It now sends a single write, which replaces the value or, if rejected, leaves it untouched. company field, person field and opportunity field (V1 writes) also deleted first; they now update a single value in place and, for multi-value fields, add the new values before removing the ones no longer wanted, so an interruption leaves extra values rather than none.
  • CLI --set on a number field sent the number as text ("5"), which Affinity rejects; combined with the delete-first bug, it emptied the field. Numbers are now sent as numbers, and anything that isn't a number is refused before any write.
  • CLI location values (--set HQ '{"city": "Paris"}') were sent as a raw string and always rejected. They are parsed as JSON objects, checked (unknown keys refused) and sent with all five keys Affinity requires.
  • CLI list entry field --append on a multi-value location (or legacy multi-value number) field would have replaced the whole list with the appended value; it now adds to it, and refuses before writing if the field would exceed 100 values.
  • CLI dropdown values written through V1 (company/person/opportunity field --set) are sent as the exact text of the matched option. V1 silently creates a new option when given text that doesn't match one exactly.
  • CLI opportunity field failed with "list_id is required for opportunity field metadata" (exit 2) for every --set, --unset and --get, since 0.5.0. It now loads the fields of the opportunity's list.
  • ListEntryService.get_field_values() (sync and async) read only the first page of …/list-entries/{id}/fields (20 fields by default), silently missing the rest. It now reads every page.
  • CLI: warnings a command returned replaced the warnings collected while it ran instead of being merged with them.
  • Company.is_global was always False for companies read through the V2 API, which sends isGlobal (the SDK read only V1's global). Both keys are read now; JSON output still uses global.

[1.17.0] - 2026-10-07

Highlights

Company and person merges no longer need --beta (or enable_beta_endpoints=True): Affinity made the merge endpoints generally available. This also fixes merges through the MCP server, which always failed because it never passes --beta. If you relied on BetaEndpointDisabledError to block merges, they now run.

Date fields can be written reliably. --set <date field> 2024-04-01 used to fail with HTTP 400, and the SDK's update_field_value() / batch_update_fields() raised TypeError for a date or datetime. Both now land on the calendar day you gave, and setting the date a field already holds is a no-op instead of a rewrite.

Fields created moments ago work in the CLI. list entry field --set and list export --field no longer fail with "not found" while Affinity's V1 field listing catches up with a new field.

Also: get_fields() returns every field in orgs with more than one page of them, the CLI's proactive rate-limit throttle now actually fires, rate_limits.refresh() works on plans without a monthly cap, and models keep the response fields Affinity added to V2 this year.

Added

  • Models now keep response fields Affinity added to the V2 API in 2026 (previously parsed and silently dropped): Opportunity.list_name / is_restricted / is_redacted (a redacted opportunity's name is masked as [Hidden]), AffinityList.created_at, ListSummary.created_at, ListEntry.list_name, and FieldMetadata.created_at / description / filterability / sortability. FieldMetadata.is_filterable / is_sortable return None when the information was not requested, so "not requested" is never reported as "not filterable". All are optional with safe defaults (V1 payloads lack them). CLI JSON output gains these keys where the models are serialized (additive).

Changed

  • Behavior change: client.companies.merge() / client.persons.merge() (sync and async) and the company merge / person merge CLI commands no longer require enable_beta_endpoints=True / --beta. Affinity moved the merge endpoints from beta to GA in
  • Code that relied on BetaEndpointDisabledError to stop merges from running will now run them. enable_beta_endpoints, --beta and BetaEndpointDisabledError remain for future beta endpoints; none currently requires them.

Fixed

  • MCP company merge / person merge always failed with BetaEndpointDisabledError, because the MCP gateway never passes --beta and the flag cannot be set by config or environment. Fixed by the merge change above.
  • CLI: the proactive rate-limit throttle in query and the bulk field commands never fired. It read X-RateLimit-Remaining, which Affinity does not send, from a lowercase-keyed dict; it now reads x-ratelimit-limit-user-remaining (any case) and pauses 0.5 s when fewer than 10 requests remain in the per-minute quota. The 429 backoff was unaffected.
  • client.rate_limits.refresh() raised a validation error when the rate-limit response had no monthly org bucket, which happens on plans with no monthly cap (documented for v2; v1 and v2 share one request pool). The org bucket now comes back empty (all fields None), matching the header-derived snapshot, and RateLimits.org_monthly is optional.
  • CLI --set on a date field with a plain date (e.g. 2024-04-01) failed with HTTP 400 "value at /value/data does not match format: date-time": the value was sent as typed, and the V2 API requires a full date-time. Date-only input is now sent as noon UTC (the same calendar date in Pacific time, where Affinity stores it); a date-time is converted to UTC; unparseable input is rejected before any write. Found by a live write test.
  • CLI list entry field --set and list export --field on a list field created moments earlier could fail with "Field not found on list" / "Unknown field". Affinity's V1 field listing (which the CLI reads for dropdown options) can lag field creation by minutes, while V2 is current; the SDK and session caches could also pin a stale copy. On a miss the CLI now refetches once — V1 bypassing the cache (skip_cache, added in 1.11.0 but never used by the CLI) and refreshing the session cache — and adds list-specific fields that V2 already knows. A dropdown field found only via V2 has no options until V1 catches up.
  • CLI field --set rewrote date fields on every run. Since 2026-01-01 Affinity stores date fields at midnight Pacific Time, so the exact-timestamp "already set" check never matched and the no-op short-circuit (1.15.0) never applied to dates. Setting the same calendar date is now a no-op: a date-only input is compared as the date typed, independent of the local timezone.
  • TaskStatus.IN_PROGRESS and MergeStatus.IN_PROGRESS are now "in-progress", the spelling in the V2 spec ("in_progress" never appears there), so comparisons against a running task's status can match. MergeStatus("in_progress") still resolves for backward compatibility. Docs and task status help updated.
  • List-entry field writes: update_field_value() / batch_update_fields() now send a datetime/date value as type datetime with an ISO-8601 date-time (a date is sent as noon UTC, which is the same calendar date in Pacific time, where Affinity stores it), as their docstrings promised; they raised TypeError: Object of type datetime is not JSON serializable instead. AsyncListEntryService.update_field_value() sent the bare value without the V2 {"type", "data"} wrapper; it now matches the sync method, including the value_type parameter. Multi-value fields still need an explicit value_type.
  • client.companies.get_fields(), client.persons.get_fields() and client.lists.get_fields() (sync and async) read only the first page of the paged V2 */fields endpoints, so orgs with more fields than one page got silently truncated metadata (and field-name resolution in the CLI could fail). They now follow pagination.nextUrl, and the merged result is what gets cached. New HTTPClient.get_all_pages() / AsyncHTTPClient.get_all_pages().

[1.16.0] - 2026-10-07

Highlights

List field values are no longer reported as null on targeted lookups. list export --company-id / --person-id, list entry get, and query direct lookups (listId + id) now return the real Status, Owner, and other list fields. Previously they returned every list field as null without a warning, and agents doing a dedup check read those nulls as real empty data. If you rely on these paths, upgrade.

You can now see what a plain company get / person get / opportunity get did not fetch: meta.notRequested lists each skipped section with the flag that fetches it (e.g. --expand list-entries). The CLI plugin (1.10.1) also stops its pre-tool hook from blocking unrelated shell commands that merely mention xaffinity, and its skill was reworked to read the right JSON keys.

Also in this release: packaging groundwork for set-once API-key entry in Claude Cowork. The CLI plugin manifest now declares a clis.xaffinity block with an env.api_key credential (AFFINITY_API_KEY, secret: true) and an api.affinity.co egress entry. This targets Claude Desktop's host-side CLI-credential broker — the user enters the key once under Customize → Plugins and it is injected into every Cowork session that runs xaffinity, with no per-project .env needed.

Status: packaged but not yet user-visible. The desktop broker (UI field + cowork-plugin-env encrypted store + invocation-time env injection) is dark-launched behind an Anthropic server-side feature gate (2307090146, internal name cli_plugin) that is off by default as of 2026-06-10. The manifest is parsed and validated correctly by the current desktop build (v1.11847.5) — verified against the binary — but the credential field will not appear until Anthropic enables the gate for an account. No plugin update will be required when it flips. Until then, end-user behavior is unchanged: provide the key via AFFINITY_API_KEY, project .env + --dotenv, or AFFINITY_API_KEY_FILE / _COMMAND (see 1.14.0).

Changed

  • CLI plugin restructured to the standard Claude Code layout: skills/ and hooks/ moved out of .claude-plugin/ to the plugin root, with .claude-plugin/plugin.json now carrying only the manifest. The marketplace source for affinity-crm-cli-xaffinity-unofficial was corrected to point at the plugin root (./plugins/xaffinity-cli) so Claude Code loads the real manifest rather than a stub. Hook test paths updated to match (tests/test_cli_{pre_xaffinity,session_setup}_hook.py).

Added

  • JSON envelope: meta.notRequested on company get, person get and opportunity get lists data the cheap default lookup skipped, each with the flag that fetches it (e.g. {"key": "data.listEntries", "flag": "--expand list-entries"}). Absent when nothing was skipped, and an explicit --no-fields is not reported. Agents were reading fields: {"requested": false} and a missing listEntries key as "no lists / empty fields".

Fixed

  • company get / person get help claimed --json returns "Full data"; it returns no field values or list entries unless requested. Help now says so and points at data.listEntries.
  • list export --company-id / --person-id returned every list field as null. The targeted path fetched each entry with a bare GET /v2/lists/{id}/list-entries/{entryId}, which omits field values unless fieldIds/fieldTypes is passed; it now requests the selected columns, the same as the streaming export path. Agents using this path for dedup checks were reading confident nulls over real data.
  • list entry get likewise showed no field values (fields.requested: false). It now requests the list's own fields (fieldTypes=list) and surfaces them in listEntry.fields with meta.resolved.fieldMetadata.
  • query on listEntries with listId + id equality (the direct-lookup fast path) returned fields.* and entityName as null. It now requests the fields the query references and normalizes the record, matching the streaming path.
  • SDK: ListEntryService.get() / AsyncListEntryService.get() accept keyword-only field_ids and field_types, matching list() / from_saved_view(). Default behavior is unchanged.

Plugins

  • CLI plugin (affinity-crm-cli-xaffinity-unofficial): 1.9.0 → 1.10.0 — added clis.xaffinity credential-broker manifest block (env.api_key → AFFINITY_API_KEY, network: ["api.affinity.co"]) for forward-compatible set-once key entry in Cowork (gated off server-side, see Highlights); plugin restructured to standard layout + marketplace source fix.
  • CLI plugin: 1.10.0 → 1.10.1 — pre-xaffinity.sh now gates only actual xaffinity invocations (command position, incl. cd x &&, $(...), VAR=v, uv run, a path to the binary). Previously any Bash command merely containing the text — grep/cat/git add of plugins/xaffinity-cli/... — was blocked when no key was configured. Skill reworked:
  • New "Read JSON from the right key" table and "null / missing does not mean empty" rule, plus a section on reading one company's / person's lists and list fields (--expand list-entries, data.listEntries, meta.notRequested).
  • Fixed wrong examples: person ls is .data.persons (was .data.rows, which silently yields a null owner ID); interaction ls / note ls / files ls return a bare .data array (was .data.interactions); primaryEmailAddress (was primaryEmail); list entry field takes the list selector; pre-write validation shipped in CLI 1.15.0 (was "v0.7").
  • SKILL.md cut from 24.1k to 14.1k characters (under the 19.9k compaction cap) by moving setup, interactions, filtering, and files/write gotchas into references/. Trigger text merged into description (Claude-only when_to_use removed). Regression prompts added under tests/skill-evals/xaffinity-cli-usage-evals/.

[1.15.0] - 2026-05-28

Highlights

Eliminates two retry hazards from a recent agent-driven intake incident: (1) the four entity-field commands (entry/company/person/opportunity field --set) now pre-validate every value up front — a bad person id or unknown dropdown option aborts with exit 2 before any API write, instead of partial-committing prior --sets. (2) Repeated --set of an unchanged value short-circuits both DELETE and POST, so agent retries no longer pollute the audit log. Plus a duplicate-note stderr warning on note create when an identical note from the same author arrived within the last 5 minutes — informational only, the create still proceeds.

Changed

  • BREAKING (strict-by-default): field --set now performs upfront client-side validation across all four entity-field commands. Failures that the SDK can detect locally (invalid person/company id, unknown dropdown option, malformed datetime, etc.) raise a single aggregated CLIError before any API write — no more partial commits when only some --set values are valid. Server-side failures (HTTP 400 mid-sequence) may still leave partial state because Affinity has no transactions. See test_entry_field_partial_failure in tests/test_cli_entry_field.py for the server-side scenario.
  • company/person/opportunity field commands gain new client-side entity-reference validation. Previously --set Owner "<full name>" raised on the server after the prior --set Status=... had already committed; now it raises locally with zero writes. The CLI also surfaces a hint pointing at person ls --query / company ls --query.
  • --output help text corrected: defaults to table regardless of pipe state. Pass --json explicitly when piping to a JSON parser.

Added

  • No-op short-circuit on field --set: when the new value already matches the existing value (dropdown id, person id, number, datetime, text), skip both the DELETE and the POST. Multi-value --set falls back to writing whenever the new set differs from the existing set (subset is NOT a no-op — set semantics is REPLACE). See per-type comparator rules in affinity/cli/field_utils.py::value_equals_existing.
  • note create --skip-duplicate-check / --duplicate-window-seconds flags. Default behavior emits a stderr warning when a content-identical note by the same author was created within the last 5 minutes; the create itself always proceeds. Useful safety net for AI agents that re-issue create after a parse failure on an earlier --json-less run.
  • Type-aware hint on _coerce_entity_id errors pointing at xaffinity person ls --json --query "<name>" or xaffinity company ls --json --query "<name>".
  • New helper module surface in affinity.cli.field_utils: pre_validate_set_operations, value_equals_existing, execute_v1_set_phase, execute_v2_set_phase, execute_append_phase.

Fixed

  • Opportunity field enriched-field writes (e.g. --set "Source of Introduction" "...") now resolve to V1 numeric ids correctly. Previous code passed the enriched V2 literal directly to FieldValueCreate, breaking enriched-field writes on opportunities. Discovered while migrating to the shared helpers.
  • entry field --append no longer captures a stale existing-values snapshot between the set and append phases — the helper refresh handoff is explicit.

[1.14.0] - 2026-05-10

Highlights

Two new environment-variable conventions for resolving the Affinity API key from external secret stores — AFFINITY_API_KEY_FILE (path to a file containing the key, used by Docker secrets, k8s mounted Secrets, and Hashicorp Vault agent) and AFFINITY_API_KEY_COMMAND (a shell command whose stdout is the key, used by 1Password CLI, pass, gpg, macOS Keychain, and git-credential-style helpers). Both conventions are industry-standard 12-factor patterns and work with from_env(), AsyncAffinity.from_env(), and the xaffinity CLI.

This release also bundles a previously-unreleased audit cleanup of the xaffinity marketplace and plugin metadata (no behavior change for users on existing install commands; brings on-disk state in line with Claude Code v2.1.120 plugin/marketplace conventions).

Added

  • AFFINITY_API_KEY_FILE — set this env var to a file path; the SDK and CLI read the API key from that file at startup. Supports ~ expansion. File permissions are checked on Posix systems; a UserWarning is emitted if the file is group- or world-readable. Useful with Docker secrets (/run/secrets/…), k8s mounted Secrets, and Hashicorp Vault agent sidecar patterns.
  • AFFINITY_API_KEY_COMMAND — set this env var to a shell command; the SDK and CLI run it at startup and use its stdout as the API key. Shell is used so that credential helper conventions (op read …, security find-generic-password -w, pass show …, vault kv get -field=…) work without quoting gymnastics. Default timeout is 30 s, overridable via AFFINITY_API_KEY_COMMAND_TIMEOUT. Non-zero exit or empty stdout raises a ConfigurationError / CLIError.
  • Both new env vars are tried between AFFINITY_API_KEY (step 2) and the existing --api-key-file / --api-key-stdin CLI flags (steps 5–6) in the resolver chain. Empty string is treated as unset at every step.
  • New internal module affinity/_internal/keyfile.py with read_key_file and read_key_command helpers shared between the SDK and CLI resolver call sites.
  • xaffinity config check-key now recognizes AFFINITY_API_KEY_FILE and AFFINITY_API_KEY_COMMAND as configured sources (reports source: "file" / "command" in JSON output). Previously the check-key helper only knew about AFFINITY_API_KEY env, .env files, and the profile config, so users who configured the key only via _FILE or _COMMAND got a misleading configured: false.
  • Marketplace (xaffinity) metadata: owner.email, owner.url, and per-plugin homepage, repository, license, and tags fields (no new install commands; metadata only).

Changed

  • CLI "missing API key" error message now enumerates all resolution paths (env vars, file/stdin flags, profile config) rather than naming a subset.
  • Plugin manifest names aligned to marketplace entry names: affinity-sdk → affinity-crm-sdk-unofficial, xaffinity-cli → affinity-crm-cli-xaffinity-unofficial. Existing install commands are unchanged for users on the published marketplace.
  • All /plugin install|update|uninstall <short>@xaffinity references in README.md, docs/public/**, mcp/.claude-plugin/xaffinity-mcp.sh, mcp/README.md, and the GitHub release install footer updated to the correct long-form identifiers (affinity-crm-{sdk,cli-xaffinity,mcp}-unofficial@xaffinity). The previous short forms had never resolved against the published marketplace.
  • CLI plugin's xaffinity-cli-usage skill: full resolution chain documented in the configured: false branch; Cowork-specific edge case now points users at project .env + --dotenv and AFFINITY_API_KEY_FILE as portable options.
  • MCP plugin's affinity-mcp-workflows skill: API-key-error troubleshooting row lists all five resolution paths.
  • Plugin skill frontmatter split into description + when_to_use (SDK and CLI plugins).

Removed

  • Dead mcp/.claude-plugin/.mcp.json (inline mcpServers in plugin.json is the source of truth).

Plugins

  • SDK plugin (affinity-crm-sdk-unofficial): 1.6.0 → 1.6.1 — plugin.json#name alignment + skill frontmatter split.
  • CLI plugin (affinity-crm-cli-xaffinity-unofficial): 1.8.0 → 1.8.2 — plugin.json#name alignment + skill frontmatter split + new AFFINITY_API_KEY_FILE / _COMMAND documentation in the resolution-chain skill content.
  • MCP plugin (affinity-crm-mcp-unofficial): 1.22.1 → 1.22.2 — troubleshooting skill row updated to list all key-resolution paths.

[1.13.1] - 2026-04-23

Highlights

Documentation corrections only — no behaviour changes. Fixes two places that cited "list-specific" as a valid fieldTypes value (it isn't; the correct V2 enum values are "enriched", "list", "global", and "relationship-intelligence").

Fixed

  • docs/public/cli-reference.md: fieldTypes example corrected from "list-specific" to the complete valid set ("global", "list", "enriched", "relationship-intelligence").
  • affinity/models/entities.py: FieldMetadata.type inline comment corrected from "list-specific" to "list" to match the actual V2 API enum value.
  • CLAUDE.md: V2 Filter "silently ignored" section now correctly documents that companies, persons, and opportunities also reject filter= (previously only mentioned list entries).

[1.13.0] - 2026-04-22

Highlights

Breaking change heads-up: xaffinity list export --filter without a scope flag now exits 2. Add --all, --max-results N, or --first-page-only to pick the scope explicitly. The previous silent page-1-only result was the root cause of the "Fusion Mantle" false-duplicate incident.

Also new: entity-scoped --company-id/--person-id flags on list export for cheap, unambiguous duplicate checks; every JSON response now exposes meta.truncated and meta.truncationReason so automated callers can't miss an incomplete answer.

Added

  • CLI: list export --company-id ID / --person-id ID (both repeatable) for entity-scoped exports that map to the V2 /companies/{id}/list-entries / /persons/{id}/list-entries endpoints. Primary use case: dedup-check before list add-entry — emits "Not on this list: company_ids=[...]" warning when a requested ID is absent. Currently scoped to company and person lists; opportunity-list support tracked separately.
  • CLI: list export --first-page-only explicit opt-in for first-page-only results (required alongside --filter if --all / --max-results are not set).
  • CLI/SDK: meta.truncated (bool) and meta.truncationReason (string) on all CommandResult envelopes. Both keys are stripped from the JSON output when None. Known values for truncationReason: firstPageOnly.
  • SDK: _entry_to_filter_dict now includes listEntryId, entityId, entityName, entityType — filtering on the row-level keys the CLI outputs no longer silently matches zero rows.

Changed (breaking)

  • CLI: list export --filter now errors (exit code 2, usage_error) without one of --all, --max-results, or --first-page-only. Previous versions silently returned first-page-only results.
  • Migration: Add the intended scope flag. For "filter all list entries" → --all; for "filter the first N" → --max-results N; for "filter what fits on page 1" → --first-page-only. On very large lists prefer --saved-view (server-side) or the new --company-id/--person-id (entity-scoped).

Fixed

  • Agents redirecting stderr to /dev/null no longer silently lose the truncation signal — it's in the JSON envelope at meta.truncated.
  • list export --filter 'entityName =~ "..."' (and other row-level keys: entityId, entityType, listEntryId) no longer silently returns zero rows. These keys were previously absent from the filter dict.

Plugin / MCP updates (shipped alongside)

  • xaffinity-cli plugin: 1.7.0 → 1.8.0 — "Common pitfalls (READ THIS FIRST)" section added to the xaffinity-cli-usage skill covering --json vs NDJSON, stderr suppression, filter-scope requirement, dedup via --company-id, and meta.truncated.
  • xaffinity-mcp server: 1.21.0 → 1.22.0 — minimum CLI bumped to 1.13.0 (mcp/COMPATIBILITY); data-model guide gains a "Duplicate checks" section with the --company-id workflow. See mcp/CHANGELOG.md for details.

[1.12.0] - 2026-04-20

Highlights

Two silent-failure classes eliminated at the SDK and CLI boundary: (1) filter= on companies.list() / persons.list() (and the query command against global entities) now raises instead of silently returning unfiltered data — server-side filtering is not supported on these list endpoints, and the previous behavior masked this; (2) companies.create() / persons.create() refuse to create a duplicate by default, raising DuplicateEntityError carrying the existing entity ID so callers can recover without producing duplicates.

Added

  • DuplicateEntityError exception (subclass of AffinityError) exposing entity_type, existing_id, existing_name, existing_domain, existing_is_global
  • companies.create(data, *, if_not_exists=True) and persons.create(data, *, if_not_exists=True) — sync and async. Defaults to pre-checking for duplicates via fuzzy search before creating
  • CLI --allow-duplicate flag on company create / person create to bypass the pre-check
  • CLI duplicate_exists error type (exit code 6) with error.details.existing.{companyId|personId, name, domain, isGlobal} for recovery
  • CLI unsupported_filter error type (exit code 2) on company ls --filter, person ls --filter, and query on companies/persons/opportunities

Changed (breaking)

  • companies.list(), persons.list(), and their .get_first() / .all() / .iter() / .pages() variants now raise ValueError if filter= is passed. Previous versions silently returned unfiltered results because server-side filtering is unsupported on these list endpoints.
  • query CLI command: where clauses on companies, persons, opportunities now raise QueryValidationError instead of silently returning all records. The schema registry's filterable_fields is empty for these entities. Use listEntries with a listId filter for list-scoped queries.
  • companies.create() / persons.create() signature adds keyword-only if_not_exists: bool = True. Existing callers that expected duplicate-creation must pass if_not_exists=False explicitly.
  • CLI company create / person create exit with code 6 (duplicate_exists) instead of creating duplicates, unless --allow-duplicate is passed.

Fixed

  • CLI company ls / person ls no longer forward the --filter argument to the SDK when it is None, avoiding false positives from the SDK-level rejection.
  • query CLI errors in --json mode now emit a structured error envelope on stdout (parseable by MCP and scripts) with error.type, error.message, error.hint, error.details.

[1.11.0] - 2026-04-19

Highlights

xaffinity person field --set "Phone Number" "..." and similar enriched-field writes now work. The SDK and CLI accept the field name or its field ID and handle the rest — including disambiguating names that exist under multiple enrichment providers (e.g. company "Industry" exists for both the built-in enricher and Dealroom), which now raises AmbiguousFieldError with a candidate table instead of silently picking one. Truly derived fields like "Current Organization" raise the new EnrichedFieldNotWritableError instead of failing with an opaque message.

Added

  • EnrichedFieldNotWritableError exception (subclass of UnsupportedOperationError) — raised when an enriched field is derived-only and cannot be written
  • FieldService.list(skip_cache=True) / AsyncFieldService.list(skip_cache=True) — bypass the field-metadata cache for fresh post-write reads
  • FieldResolver.to_v1_numeric(client, field_id, entity_type) on the CLI resolver — resolves field IDs to the numeric form the write path needs, handling enriched IDs transparently

Fixed

  • person field --set / person field --unset / company field --set / company field --unset now correctly handle enriched field IDs in addition to field names and numeric IDs
  • Ambiguous enriched field names (e.g. company "Industry") are now disambiguated by enrichment provider instead of silently picking one of the matching fields

[1.10.0] - 2026-04-05

Highlights

You can now resolve field names to FieldMetadata (including FieldId) using FieldResolver.find_field(), which is what you need for write operations like update_field_value(). Unlike get() which silently picks the first match for ambiguous names, find_field() raises AmbiguousFieldError to prevent accidentally updating the wrong field. Skill descriptions across all three plugins have been rewritten for clearer triggering boundaries when multiple plugins are installed.

Added

  • FieldResolver.find_field(name) method — resolves field names to FieldMetadata for write operations
  • AmbiguousFieldError exception — raised when find_field() encounters ambiguous field names; includes candidate IDs and disambiguation hints

Changed

  • Plugin skills: Clarified triggering boundaries across MCP, CLI, and SDK plugins — MCP workflows skill is now explicitly primary when MCP tools are available, CLI skill defers to MCP for general CRM tasks
  • Plugin skills: Pipeline-history skill rewritten with MCP tool examples (was raw bash), query-language skill no longer depends on workflows-guide resource
  • Plugin skills: SDK skill now documents FieldResolver.find_field() patterns for read vs. write field resolution

[1.9.0] - 2026-03-22

Highlights

Accessing a company or person that was merged now raises a dedicated MergedEntityError (or CompanyMergedError/PersonMergedError) instead of a generic ValidationError. The exception provides structured source_id and target_id attributes, so you can programmatically follow merges without regex-parsing error messages. The CLI also surfaces this as a distinct entity_merged error type with structured JSON details.

Added

  • MergedEntityError, CompanyMergedError, PersonMergedError exception classes — raised on 422 responses indicating an entity was merged into another
  • CLI: entity_merged error type with sourceId, targetId, entityType in JSON error details (exit code 4)

[1.8.2] - 2026-03-19

Highlights

Added a comparison guide documenting how this project differs from Affinity's official MCP Server (beta), covering API coverage, tool architecture, deployment options, and when to use each.

Added

[1.8.1] - 2026-03-16

Highlights

InteractionDirection, InteractionLoggingType, and ListRole are now available from affinity.types — previously they could only be imported from the internal affinity.models.types path. The Interaction model now also captures the logging_type field from the API instead of silently dropping it.

Fixed

  • Re-export InteractionDirection, InteractionLoggingType, and ListRole from affinity.types — these were the only public enums missing from the stable import path
  • Add logging_type field to Interaction model — the API returns this field but it was silently discarded

[1.8.0] - 2026-03-10

Highlights

entry field --get now returns resolved person and company objects (with firstName, lastName, primaryEmailAddress, etc.) instead of raw integer IDs. This matches the behavior of list export and provides a consistent, AI-agent-friendly experience. Under the hood, the command switched from V1 to V2 API.

CLI Plugin 1.6.1

Added

  • Skill: documented file commands (company files, person files, opportunity files) with ls, download, read, upload subcommands — prevents unnecessary fallback to raw v1 API calls
  • Skill: added file commands to Quick Reference table

Changed

  • Breaking: entry field --get output for person/company reference fields now returns resolved objects instead of raw integer IDs. Scripts that parse the integer ID should update to read from the resolved object.
  • entry field --get now uses the V2 API (/v2/lists/{listId}/list-entries/{entryId}/fields) instead of V1 field_values.list()

Fixed

  • ListEntryService.get_field_values() (sync and async) now correctly parses V2 API list response format — previously returned empty results due to dict/list type mismatch
  • Added types to REPEATABLE_QUERY_PARAMS so the V2 fields endpoint types filter parameter is correctly encoded
  • FieldValues._coerce_from_api no longer double-wraps dicts that are already in {requested, data} format — fixes fields.* returning null after round-trip serialization
  • Query executor now warns (via ctx.warnings) when field metadata fetch fails instead of silently returning null for all fields.* values
  • Query executor now warns when field name resolution fails in where clauses instead of silently skipping resolution
  • Multi-parent fetch path now normalizes list entry fields — fields.* in select works correctly when query spans multiple parent lists

[1.7.3] - 2026-03-08

Highlights

UserId is now a subtype of PersonId, reflecting that workspace users are internal persons in Affinity's data model. Code that receives a UserId (e.g., Note.creator_id) can now pass it directly to any API accepting PersonId — no casting required.

Changed

  • UserId now extends PersonId instead of IntId — isinstance(UserId(1), PersonId) is now True. This is an intentional runtime behavior change: workspace users are internal persons in Affinity's data model. Code that used isinstance to distinguish users from persons should use person.type == PersonType.INTERNAL instead.
  • Simplified internal casts that previously converted UserId → int → PersonId

Documentation

  • data-model.md: clarified that user IDs and person IDs share the same ID space

[1.7.2] - 2026-03-03

Highlights

The xaffinity CLI plugin's SessionStart hook no longer runs pip install on every container start. Installation is deferred to first actual use via a self-installing wrapper, cutting session startup from ~30s to <1s in ephemeral environments like Cowork. Skill descriptions now display correctly in the Claude Code UI. Also documents two Affinity API limitations discovered via support: interaction entity association and enriched field constraints.

CLI Plugin 1.6.0

Added

  • Lazy install: SessionStart hook now drops a lightweight self-installing wrapper instead of running pip install unconditionally. The wrapper defers installation to first actual xaffinity use, with mkdir-based locking for concurrent safety, a marker file for install failure detection, and a 45s wait timeout for concurrent invocations.
  • PreToolUse hook: Install-failure detection via $HOME/.xaffinity-install-status marker — blocks commands with a clear error when pip install failed, instead of cryptic failures.
  • PreToolUse hook: Lazy session cache start — xaffinity session start is triggered on first xaffinity command instead of at session start, with CLAUDE_ENV_FILE persistence.

Changed

  • SessionStart timeout reduced from 60s to 10s (hook is now lightweight)
  • PreToolUse Bash timeout increased from 10s to 60s (accommodates pip install via wrapper on first use)
  • SessionStart status message updated to "Preparing Affinity CLI environment..."

Fixed

  • Skill YAML frontmatter: replaced multi-line > scalar with single-line description (Claude Code couldn't parse folded scalars, showing > instead of the description)
  • Skill: updated session cache documentation to reflect lazy initialization

Documentation

  • Skill: documented that interactions cannot be associated with companies/opportunities via API (UI's "Also add to" feature has no API equivalent)
  • Skill: documented that "Current Organization" is read-only via API and "Current Job Title" requires a separate field update after person creation

SDK Plugin 1.5.5

Fixed

  • Skill YAML frontmatter: replaced multi-line > scalar with single-line description (same parsing fix as CLI plugin)

Documentation

  • Data model: documented interaction entity association limitation and enriched field constraints (Current Organization, Current Job Title)

[1.7.1] - 2026-03-01

Highlights

Plugin skills now guide agents to consolidate multi-source CRM queries into single scripts instead of running separate commands that dump raw JSON into the conversation. The CLI plugin also auto-starts session caching at session begin, so metadata is shared across all commands without manual setup.

CLI Plugin 1.5.6

Added

  • Skill: "Multi-Source Tasks" section guiding agents to write consolidated bash scripts (with session cache + jq) instead of running separate CLI commands that dump raw JSON into context
  • Skill: "Extract only what you need" guidance with jq examples for single-command output filtering
  • Skill: session cache fallback instruction for environments without automatic setup
  • SessionStart hook: automatic session cache initialization (xaffinity session start) with CLAUDE_ENV_FILE persistence for Cowork

SDK Plugin 1.5.4

Added

  • Skill: "Multi-Source Tasks: Output Only the Summary" section guiding agents to print concise summaries instead of raw model_dump_json() output when combining multiple data sources

[1.7.0] - 2026-02-21

Highlights

New field history-bulk command fetches field change history across an entire list in one shot — useful for pipeline stage analysis, funnel conversion, and time-in-stage metrics. Also fixes --dotenv to search upward for .env files (matching standard dotenv behavior) and hardens the PreToolUse hook with a three-tier API key check.

Added

  • CLI: field history-bulk command for batch field change history across lists. Supports --list-id (with --all/--max-results bounding), --list-entry-ids for specific entries, --action-type filtering, --dry-run for API cost estimation, and concurrent fetching via XAFFINITY_CONCURRENCY env var. Partial failures are reported as warnings without blocking successful entries.
  • CLI: --list alias for --list-id on field ls and field history-bulk commands (LLM-friendly shorthand).
  • MCP Plugin: Pipeline history analysis skill (pipeline-history) with 5-step workflow: identify status field, export current state, dry-run estimate, fetch history, analyze transitions.

Fixed

  • CLI: --dotenv now searches upward for .env files using find_dotenv(usecwd=True), matching standard python-dotenv behavior. Previously only checked the current working directory, which failed in Cowork VM sessions where the working directory differs from the .env location.
  • CLI Plugin: PreToolUse hook (pre-xaffinity.sh) now uses three-tier API key detection: (1) AFFINITY_API_KEY env var, (2) --dotenv check-key, (3) plain check-key (config.toml). Previously only checked the env var, blocking commands when the key was configured via .env or config file.

Documentation

  • Added list export --json output structure documentation to data model resource (field key details, --field/--field-type requirement).
  • Added field history-bulk to MCP command registry for LLM discoverability.
  • Added field change history section to data model resource.

CLI Plugin 1.5.5

Fixed

  • PreToolUse hook: three-tier API key detection (env var → dotenv → config.toml)

MCP Plugin 1.19.0

Added

  • Pipeline history analysis skill for deal stage transition workflows

[1.6.2] - 2026-02-17

Highlights

Update notifications now work reliably. Previously, having multiple Python environments (e.g., dev virtualenv + system install) could silently poison the update cache, and the first config update-check run always said "never checked" instead of just checking.

Fixed

  • CLI: config update-check now checks PyPI inline when cache is missing or stale, instead of showing "never checked"
  • CLI: Background update worker now receives version from spawning process, fixing version mismatch when multiple Python environments share a cache
  • CLI: Smarter cache invalidation — upgrading no longer discards the cache; it adapts it

[1.6.1] - 2026-02-16

Highlights

config check-key --env-file <path> now correctly reads the specified file instead of only looking in the current directory. Fixes key discovery in Cowork VM sessions where the .env is on a mounted path.

Fixed

  • CLI: config check-key ignored --env-file flag — always checked CWD/.env instead of the user-provided path

[1.6.0] - 2026-02-16

Highlights

New --include-me flag for interaction create auto-includes your person ID. Also fixes field writes for person/company multi-value fields -- entity IDs are now wrapped correctly for the V2 API, and --append properly merges instead of replacing.

Added

  • CLI: interaction create --include-me flag to auto-include current user's person ID via whoami
  • CLI: Enhanced validation error hint for interaction person_ids constraint (internal/external requirement)

Changed

  • Added interaction create guidance to CLI plugin skill
  • Added interaction create examples to MCP data model resource

Fixed

  • CLI: Person/company field writes (--set, --append) now correctly wrap entity IDs in {"id": <int>} for the V2 API. Previously sent raw integers, causing validation_error: value at /value/data is not null.
  • CLI: --append on person-multi/company-multi fields now merges with existing values instead of silently replacing them (same fix already existed for dropdown-multi).
  • CLI: Repeated --append on the same multi-value field (e.g., --append Tags A --append Tags B) now aggregates into a single write, preventing the second write from overwriting the first.
  • CLI: V1 API allows_multiple promotion now applies to person and company field types (previously only dropdown was promoted to its -multi variant).
  • MCP command registry: corrected --participants to --person-id for interaction create

Plugin Releases — 2026-02-15

Plugin versions are now independent from the SDK version (see VERSIONING.md).

CLI Plugin 1.5.3

Changed

  • Plugin versions are now independent from SDK version

Fixed

  • Security: SessionStart hook no longer exports AFFINITY_API_KEY to the environment. The key stays in .env and is read per-command via --dotenv, preventing the LLM from accessing it via env or echo $AFFINITY_API_KEY.

SDK Plugin 1.5.3

Changed

  • Rewrote affinity-python-sdk skill description following Anthropic skills guide formula

MCP Plugin 1.18.1

Changed

  • Rewrote query-language SKILL.md (861→293 lines) with progressive disclosure; extracted detail to 4 reference files
  • Updated affinity-mcp-workflows skill description following guide formula

[1.5.2] - 2026-02-14

Highlights

Plugin improvements for Claude Code and Cowork: automatic environment setup via SessionStart hook, API key protection via .env read guard, and clearer plugin names in marketplace listings.

Added

  • CLI Plugin: SessionStart hook (session-setup.sh) for Cowork bootstrap — installs xaffinity, sets PATH, loads API key from .env
  • CLI Plugin: PreToolUse/Read guard (guard-env-read.sh) blocks reading .env files to prevent API key exposure in conversation
  • CLI Plugin: Query command reference (references/query-guide.md)
  • MCP Plugin: Query language reference files (references/filter-operators.md, quantifiers.md, include-expand.md, output-formats.md)

Changed

  • Marketplace: Renamed plugins from generic "sdk"/"cli"/"mcp" to "Affinity CRM SDK (unofficial)", "Affinity CRM CLI (xaffinity, unofficial)", "Affinity CRM MCP (unofficial)" for clarity in plugin lists.
  • CLI Plugin: Rewrote skill description following Anthropic skills guide formula
  • MCP Plugin: Rewrote query-language SKILL.md (861→293 lines) with progressive disclosure; extracted detail to references/
  • MCP Plugin: Updated affinity-mcp-workflows skill description following guide formula
  • SDK Plugin: Updated affinity-python-sdk skill description following guide formula

Removed

  • CLI Plugin: Removed /affinity-help command (redundant — CLI skill auto-triggers on relevant prompts)

Fixed

  • CLI Plugin: .env parsing now handles quoted values and CRLF line endings
  • CLI Plugin: SessionStart hook writes to CLAUDE_ENV_FILE are idempotent (no duplicate lines on re-run)

[1.5.1] - 2026-02-12

Highlights

Fixes writing to multi-select dropdown fields. --set and --append now correctly handle the array format required by the API, and --append properly merges with existing selections instead of replacing them.

Fixed

  • CLI: --set and --append now work correctly for dropdown-multi fields when V1 API returns value_type="dropdown" with allows_multiple=True. Previously, resolve_dropdown_value only checked value_type (not allows_multiple) to determine the payload format, sending {"dropdownOptionId": ID} instead of [{"dropdownOptionId": ID}].
  • CLI: --append for dropdown-multi fields now merges with existing values instead of replacing them. The V2 API replaces the entire option array on POST, so --append now reads existing selections, adds the new option (deduplicating), and sends the combined array.

[1.5.0] - 2026-02-11

Highlights

Interaction queries are now much more robust. Date ranges are validated up front, ranges over 1 year are auto-chunked seamlessly, and mixing naive/timezone-aware datetimes is caught with a clear error. Breaking: InteractionService.list() now requires start_time, end_time, and an entity ID -- callers already getting 422 errors will now get a clear SDK-level message instead.

Changed

  • Breaking: InteractionService.list() and AsyncInteractionService.list() now require start_time, end_time, and at least one entity ID (person_id, company_id, or opportunity_id). Previously these were optional, but the API always rejected calls without them (422). Callers that were passing None for these parameters were already getting API errors; this change surfaces the requirement at the SDK level with clear error messages.
  • InteractionService.list() now validates date ranges: start_time must be before end_time, and the range must not exceed 365 days.

Added

  • SDK: InteractionService.iter() and AsyncInteractionService.iter() now automatically chunk date ranges exceeding 365 days. Large ranges are split into <=365-day chunks with synthetic cursors bridging them, making iteration seamless.
  • SDK: iter() defaults end_time to datetime.now(timezone.utc) when not provided, so callers only need to specify start_time.
  • SDK: Timezone consistency validation — mixing naive and timezone-aware datetimes raises ValueError with guidance instead of a raw TypeError.
  • SDK: _chunk_date_range() utility for splitting date ranges into API-compatible chunks.
  • CLI: Query executor now properly fetches interactions for include and expand operations. Previously, these paths called interactions.list() without required type/date parameters and silently returned empty results.

Fixed

  • SDK: Fixed falsy truthiness bugs in InteractionService.list() where PersonId(0), CompanyId(0), page_size=0, and empty page_token="" were incorrectly dropped. Changed if x: to if x is not None: for all optional parameters.

[1.4.1] - 2026-02-11

Highlights

CLI errors from invalid commands or options now produce proper JSON error envelopes when --json is active, fixing broken downstream JSON parsers.

Fixed

  • CLI: Click-level errors (unknown commands, invalid options) now emit a proper JSON error envelope ({"ok": false, "error": {...}}) when --json or --output json is active. Previously, stdout was empty and only plain text went to stderr, breaking downstream JSON parsers.
  • CLI: normalize_exception() now handles click.UsageError (→ usage_error, exit code 2) and click.ClickException (→ error, exit code from exception) instead of misclassifying them as internal_error.

[1.4.0] - 2026-02-10

Highlights

FieldResolver now resolves all value types to human-readable text -- not just dropdowns, but also persons, companies, locations, and interactions. Dropdown fields return rich DropdownOption objects with .text, .rank, and .color. Breaking: get_value() returns DropdownOption instead of raw IDs, and resolve_dropdowns parameter is replaced by ResolveMode.

Changed

  • Breaking: FieldValues.get_value() now returns DropdownOption objects for dropdown/ranked-dropdown fields instead of raw int IDs. The DropdownOption has .id, .text, .rank, and .color attributes. This applies to all dropdown value types (dropdown, ranked-dropdown, dropdown-multi).
  • Breaking: Removed FieldType.LIST_SPECIFIC. Use FieldType.LIST instead. The V2 API uses "list" uniformly; "list-specific" was never a valid V2 value.
  • Breaking: FieldResolver.get() parameter resolve_dropdowns: bool replaced with resolve: ResolveMode (ResolveMode.RAW or ResolveMode.TEXT). ResolveMode.TEXT resolves dropdowns, persons, companies, and locations to human-readable strings.

Added

  • SDK: ResolveMode enum (RAW, TEXT) for controlling FieldResolver value resolution.
  • SDK: ResolveMode.TEXT now resolves person, company, location, and interaction fields to human-readable strings (not just dropdowns).
  • SDK: FieldResolver supports source-qualified field names ("dealroom:Description") for disambiguating enrichment fields with the same display name. Ambiguous bare names emit a one-time warning at access time.
  • SDK: validate_entity_field_types() raises ValueError when FieldType.LIST is passed to company/person endpoints (which only accept ENRICHED, GLOBAL, RELATIONSHIP_INTELLIGENCE).
  • SDK: DropdownOption.color now accepts both int (V1) and str (V2) values.

Fixed

  • SDK: FieldValues._extract_value() now correctly recurses through {"data": {...}} envelopes for dropdown values, fixing ranked-dropdown and dropdown-multi extraction that previously returned the raw envelope dict.
  • SDK: Dropdown text resolution now works without V1 field metadata. Previously, FieldResolver built a lookup table from FieldMetadata.dropdown_options (V1-only; always empty on V2). Now text is read directly from the DropdownOption object extracted from the field value.
  • SDK: async_check_unreplied() replaced broken no-type iter() call with asyncio.gather() per-type pattern for parallel interaction fetching.
  • CLI: _extract_person_display_name and _extract_person_name now delegate to shared resolve_person() utility.

Documentation

  • Added FieldResolver usage example to README "Working with Lists" section.
  • Added FieldResolver mention to getting-started guide field gotchas, linking to performance guide.
  • Replaced raw field count with FieldResolver usage in examples/basic_usage.py.
  • Updated field-types-and-values guide: dropdown types now document DropdownOption return type.
  • Added "List Entry Field Access" section to performance guide explaining entry.entity.fields delegation.

[1.3.2] - 2026-02-08

Highlights

Fixes writing to dropdown-multi fields via --set and --append. Previously all dropdown-multi writes failed with a type mismatch error.

Fixed

  • CLI: --set and --append now work for dropdown-multi fields. Previously, resolve_dropdown_value() only handled dropdown and ranked-dropdown, causing all dropdown-multi writes to fail with "Field value type should be dropdown-multi" errors. The fix resolves option text/IDs and wraps them in the array format required by the V2 API.

[1.3.1] - 2026-02-06

Highlights

FieldResolver now works correctly with list entry objects -- it auto-delegates to the inner entity when fields were fetched on the entity rather than the list entry directly.

Fixed

  • SDK: FieldResolver.get() and get_by_id() now automatically delegate to the inner entity when called with a ListEntryWithEntity whose fields were not directly requested. Previously warned and returned None even when entry.entity.fields were populated.
  • SDK: FieldResolver duplicate field name warning now includes enrichment source (e.g., "dealroom" vs "affinity-data") instead of raw IDs, making enrichment provider collisions immediately clear.

[1.3.0] - 2026-02-06

Highlights

New list_batch() method on AsyncEntityFileService for fetching files across multiple entities concurrently with auto-pagination.

Added

  • SDK: list_batch() on AsyncEntityFileService for fetching files across multiple entities concurrently with auto-pagination. Accepts person_ids, company_ids, or opportunity_ids with max_concurrent and on_error ("raise"/"skip") parameters.

[1.2.0] - 2026-02-06

Highlights

New async batch methods for files and reminders: batch_get() on AsyncEntityFileService and list_batch() on AsyncReminderService for concurrent multi-entity operations. Also fixes falsy ID bugs across reminder and file services.

Added

  • SDK: batch_get() on AsyncEntityFileService for fetching file metadata concurrently with controlled concurrency. Supports max_concurrent and on_error ("raise"/"skip") parameters.
  • SDK: list_batch() on AsyncReminderService for fetching reminders across multiple entities concurrently with auto-pagination. Accepts person_ids, company_ids, or opportunity_ids with common filters.

Fixed

  • SDK: Fixed truthiness bugs in ReminderService.list(), AsyncReminderService.list(), EntityFileService.list(), and AsyncEntityFileService.list() where PersonId(0), CompanyId(0), and other falsy values were silently dropped from query parameters. Changed all if <param>: guards to if <param> is not None:.
  • SDK: Fixed incorrect ReminderStatus docstrings that referenced non-existent "SNOOZED" and "COMPLETE" values. The actual enum values are COMPLETED, ACTIVE, and OVERDUE.

[1.1.0] - 2026-02-05

Highlights

Major convenience release: FieldResolver for looking up field values by name instead of ID, batch_get() for concurrent entity fetching, read_only() factory for safe clients, and get_first() for quick single-entity lookups. Working with Affinity field data is now significantly easier.

Added

  • SDK: CompanyService.get_many(company_ids) convenience method for batch fetching multiple companies in a single API call. Alias for list(ids=[...]) with better discoverability.
  • SDK: FieldValues.get(field_id) convenience method for safer access to field values by ID. Returns the field value dict or None if not found.
  • SDK: Affinity.read_only() and Affinity.read_only_from_env() factory methods (and async variants) for creating clients that block all write operations. Useful for read-only scripts and dashboards.
  • SDK: get_first() convenience method on CompanyService, PersonService, OpportunityService, ListService, and ListEntryService (sync + async). Returns the first matching entity or None.
  • SDK: batch_get() on AsyncCompanyService, AsyncPersonService, and AsyncOpportunityService for fetching multiple entities with controlled concurrency. Supports max_concurrent and on_error ("raise"/"skip") parameters.
  • SDK: FieldValues.get_value(field_id) for extracting the unwrapped field value (e.g., returns "Active" instead of {"data": "Active"}). Handles text, dropdown, multi-value, and location fields.
  • SDK: FieldResolver helper class for looking up field values by name instead of ID. Supports case-insensitive matching, batch extraction via get_many(), and dropdown option resolution.
  • Docs: "Field Lookup Patterns" section in performance guide documenting FieldResolver usage, low-level access, and field metadata sources.

Fixed

  • SDK: FieldService.list() now caches results for 5 minutes (matching V2 field endpoints). Previously only V2 endpoints (companies.get_fields(), persons.get_fields()) were cached.
  • SDK: FieldService.list(list_id=ListId(0)) no longer skips the list_id parameter. The falsy ID guard (if list_id:) was changed to if list_id is not None:.

[1.0.3] - 2026-02-03

Highlights

Fixes MCP timeout failures during large query include operations by emitting incremental progress every 10 records instead of only at start/end.

Fixed

  • CLI: Query include operations now emit incremental progress every 10 records during N+1 API calls. Previously, progress was only emitted at the start and end of include steps, causing MCP timeout to expire during long-running N+1 operations (e.g., 100 records with include: ["persons"]). The fix changes from asyncio.gather() to asyncio.as_completed() for incremental progress emission.

[1.0.2] - 2026-02-03

Highlights

Fixes descending sort for string fields in queries (dates, names were silently returning ascending order) and corrects API call estimates for sorted queries that could cause premature timeouts.

Fixed

  • CLI: Query orderBy with descending direction now works correctly for string values (dates, names, etc.). Previously, descending sort on non-numeric fields silently returned results in ascending order because string values cannot be negated. The fix uses a comparison-inverting wrapper class for proper descending sort.
  • CLI: Query dry run now correctly estimates API calls when orderBy is present. Previously, estimatedApiCalls used the limit value even though sorting requires fetching all records first. A query with limit: 200 and orderBy on a 9000-record list showed 4 API calls instead of ~90. This caused dynamic timeout calculations to be too short.

[1.0.1] - 2026-02-01

Highlights

Fixes unreplied email/chat detection (--check-unreplied and expand: ["unreplied"]) which was silently failing, and adds required type parameter validation to InteractionService with clear error messages instead of cryptic 422s.

Fixed

  • SDK: InteractionService.list() and iter() now validate that type parameter is required, raising ValueError with clear guidance instead of failing with cryptic API 422 errors. The Affinity V1 API has always required this parameter.
  • CLI: --check-unreplied and expand: ["unreplied"] now work correctly. Previously, all unreplied checks silently failed (returning null) due to missing type parameter in interaction API calls.
  • CLI: Query dryRun JSON output now includes a warning when estimated API calls exceed 50, helping LLMs notice expensive operations.

[1.0.0] - 2026-01-31

First stable release. The SDK and CLI APIs are now considered stable. Breaking changes will follow semantic versioning (major version bumps).

Added

  • CLI: Auto-update notifications. The CLI now checks PyPI daily (in background) and displays a notification when a new version is available. Notifications are suppressed in non-interactive environments (--quiet, --output json, CI, no TTY). Disable with XAFFINITY_NO_UPDATE_CHECK=1 or update_check = false in config.
  • CLI: xaffinity config update-check --background flag for non-blocking update checks. Used by MCP server to trigger background updates without waiting for results.

Fixed

  • MCP: Add AFFINITY_API_KEY to environment variable allowlist. API keys configured in Claude Desktop's MCP settings were being silently dropped, requiring manual config file creation as a workaround.
  • MCP: Detect CLI in macOS Python framework path (/Library/Frameworks/Python.framework/Versions/*/bin/). Users who installed via pip install with python.org Python were getting "xaffinity: command not found" errors.

[0.16.1] - 2026-01-27

Added

  • Docs: "Verify Installation" section in CLI documentation with step-by-step health check instructions (xaffinity --version, AFFINITY_API_KEY=... xaffinity whoami).

[0.16.0] - 2026-01-26

Changed (Breaking)

  • SDK: Person.interactions and Company.interactions attribute type changed from dict[str, Any] to typed Interactions model. Migration: change dict access (person.interactions["last_event"]["person_ids"]) to attribute access (person.interactions.last_event.person_ids). The Interactions model provides typed access to first_email, last_email, first_event, last_event, next_event, last_chat_message, and last_interaction fields, each containing an InteractionEvent with date and person_ids attributes.

Fixed

  • SDK: PersonService.get() field values are now properly validated as FieldValue models.

[0.15.1] - 2026-01-25

Fixed

  • CLI: query --output json now supports cursor-based pagination when output is truncated. Previously, JSON truncation happened at the MCP layer after CLI execution, so no cursor was emitted. Now JSON truncation happens in the CLI layer (same as other formats), enabling proper resumption via --cursor.

[0.15.0] - 2026-01-23

Added

  • CLI: company files read, person files read, opportunity files read commands to read file content with chunking support. Returns base64-encoded content with metadata (size, offset, length, hasMore, nextOffset). Use --offset and --limit to fetch large files in chunks. Default chunk size is 1MB.

Fixed

  • CLI: --env-file now implicitly enables dotenv loading when an explicit file path is provided (not the default .env). Previously, using --env-file .sandbox.env without --dotenv would silently ignore the file.
  • CLI: Fixed dropdown field value resolution in entry field --set and --append commands. The V2 API requires {"dropdownOptionId": ID} format, but the CLI was sending raw values. Now dropdown text (e.g., "In Progress") or numeric IDs are properly resolved to the V2 API format.
  • CLI: List field metadata now fetched from V1 API to include dropdown_options, which V2 API omits.

0.14.0 - 2026-01-22

Added

  • CLI: company files ls, person files ls, opportunity files ls commands to list files attached to entities without downloading them. Supports pagination (--page-size, --cursor, --max-results, --all), selector resolution (ID, URL, name, domain, email), and MCP safety limits.
  • CLI: file-url command to get presigned download URLs for files. Returns URL valid for 60 seconds along with file metadata (name, size, contentType). Useful for programmatic access and MCP workflows.
  • CLI: files download --file-id option for single-file downloads. Downloads one file directly without creating a manifest.
  • SDK: FilesService.get_download_url(file_id) method to get presigned download URLs without downloading content. Returns PresignedUrl with URL, file metadata, and expiration info.
  • SDK: PresignedUrl dataclass exported from affinity package with fields: url, file_id, name, size, content_type, expires_in, expires_at.

Changed

  • CLI: files dump renamed to files download for clarity.

Fixed

  • SDK: CompanyService.get() and PersonService.get() now automatically fall back to V1 API when V2 returns 404. This handles V1→V2 eventual consistency issues where a search (company ls --query, person ls --query) finds an entity via V1, but a subsequent get fails because V2 hasn't synced yet. The fallback is transparent and requires no code changes.

Known Issues

  • MCP: get-file-url tool returns valid presigned URLs, but Claude Desktop's WebFetch cannot access userfiles.affinity.co due to domain sandbox restrictions. This affects all Claude Desktop users - neither "Additional allowed domains" nor "All domains" settings work around this limitation (#19087, #11897). Workaround: Use files read command (returns content inline), copy URL to browser, or use CLI directly with files download --file-id.

0.13.1 - 2026-01-21

Fixed

  • CLI: Query progress now emits progress: 0 in on_step_start events, enabling mcp-bash timeout extension for slow first-page fetches

0.13.0 - 2026-01-21

Changed (Breaking)

  • SDK: AffinityList.list_size field removed. The V2 API returns incorrect values (often 0 for non-empty lists). Use client.lists.get_size(list_id) instead, which fetches from the V1 API and caches for 5 minutes.

Added

  • SDK: ListService.get_size(list_id) and AsyncListService.get_size(list_id) methods to get accurate list size from the V1 API with automatic caching (5 minutes). Use force=True to bypass cache when fresh data is critical.

0.12.0 - 2026-01-20

Added

  • CLI: query --cursor option for resumable pagination when responses are truncated (exceed --max-output-bytes):
  • Streaming mode (simple queries): O(1) resumption via stored Affinity API cursor - no re-fetching of previous pages
  • Full-fetch mode (queries with orderBy/aggregate): Results cached to disk, zero API calls on resume
  • Cursor emitted to stderr as NDJSON {"type": "cursor", "cursor": "...", "mode": "..."} for MCP extraction
  • CLI exits with code 100 when truncated (cursor available)
  • Cache auto-cleanup on startup: LRU eviction (500MB limit), 1-hour TTL
  • Validation: query hash, format mismatch, cache tampering all detected with clear errors

0.11.0 - 2026-01-20

Added

  • CLI: Full scan protection when running via MCP gateway. Commands with pagination (list export, person ls, company ls, etc.) now enforce limits: default 1000 records, max 10000 records. The --all flag is blocked with a clear error message guiding users to use explicit --max-results or cursor pagination instead.

Changed

  • CLI: --csv is now an alias for --output csv (consistent with --json being an alias for --output json)
  • CLI: CSV sub-options (--csv-bom, --csv-header, --csv-mode) auto-enable CSV output when no format is specified
  • CLI: Error messages for output flag conflicts no longer have trailing periods (e.g., --csv and --json are mutually exclusive instead of ending with .)

Removed

  • Breaking: CLI: --pretty flag removed from query command (use | jq . for pretty JSON output)

Fixed

  • CLI: Query engine now correctly computes entityName for Person list entries. Previously, entityName was null for persons because the API returns firstName/lastName instead of name. Now uses the same display name logic as list export.
  • CLI: Table/CSV formatters now correctly detect Person entities with real API types (external/internal) or no type field. Previously, formatters only checked for type="person" which the API never actually returns, causing Person data to display as "object (N keys)" instead of "John Smith (id=123)".

Performance

  • CLI: Session cache now caches person resolution (by email/name) and company resolution (by domain/name), reducing API calls when running multiple commands in a session pipeline.
  • CLI: Person and company field resolution now uses session cache, avoiding redundant field definition fetches.

0.10.0 - 2026-01-19

Fixed

  • SDK: Removed unsafe asyncio.create_task() in AsyncAffinity.__del__ that could cause tasks to be garbage collected before completion. Users must now use context managers or call close() explicitly.
  • SDK: Added thread-safe locking to SimpleCache for concurrent access.
  • SDK: Fixed async client initialization race condition with asyncio lock.
  • SDK: Fixed stream context manager cleanup on errors in HTTP client.
  • SDK: Fixed empty/whitespace content handling in JSON response parsing.
  • SDK: Added error handling for partial file cleanup on download failures.
  • SDK: Improved filename sanitization for server-provided filenames (null bytes, control chars).
  • CLI: Added validation for --timeout, --max-columns, --max-records, --max-output-bytes to reject non-positive values.
  • CLI: Added validation for --env-file to check file exists when --dotenv is enabled.
  • CLI: Added file size limit (1MB) for config files to prevent memory exhaustion.
  • CLI: Added file permission warnings for API key files (same as config files).
  • CLI: Fixed TOML escaping to handle newlines, tabs, and carriage returns in API keys.
  • CLI: Fixed JSON/TOML parse error handling with helpful error messages.
  • CLI: Fixed type validation for list access in CSV/table output (verify first element is dict).
  • CLI: Fixed exponential backoff calculation to cap exponent before computing power.
  • CLI: Let AuthenticationError/AuthorizationError propagate in query executor instead of silent catch.
  • CLI: Added logging to silent exception handlers in entity validation.

Changed (Breaking)

  • CLI: --check-unreplied-emails renamed to --check-unreplied with support for both email and chat messages.
  • New --unreplied-types flag: comma-separated list of types to check (email, chat, all). Default: email,chat
  • Output now includes type field ("email" or "chat") in addition to date, daysSince, and subject
  • Chat messages have null subject (no subject attribute)
  • Cross-type reply detection: email replied via chat (or vice versa) counts as "replied"
  • CLI: Query expand: ["unrepliedEmails"] renamed to expand: ["unreplied"]
  • Same cross-type reply detection and multi-type support as CLI flag
  • CSV columns renamed: unrepliedEmailDate → unrepliedDate, unrepliedEmailDaysSince → unrepliedDaysSince, unrepliedEmailSubject → unrepliedSubject, plus new unrepliedType column

0.9.14 - 2026-01-19

Fixed

  • CLI: Query select clause now automatically includes expand fields. Previously, using select with expand would filter out the expansion data (e.g., interactionDates, unreplied), requiring users to explicitly list expansions in select.

0.9.13 - 2026-01-19

Added

  • CLI: Query include for listEntries now supports persons, companies, opportunities, and interactions. Fetches related entities based on list entry entity type (e.g., company entries get associated persons).
  • CLI: Query expand: ["unrepliedEmails"] now works with listEntries. Checks each list entry's underlying entity for unreplied incoming emails.
  • CLI: Query extended include syntax with parameters:
  • {include: {interactions: {limit: 50, days: 180}}} - Limit and lookback control
  • {include: {opportunities: {list: "Pipeline"}}} - Scope to specific opportunity list
  • {include: {persons: {where: {...}}}} - Filter included entities

Fixed

  • CLI: Query TOON format now correctly flattens fields.* and interactionDates like markdown and CSV formats do. Previously, TOON was missing the _apply_explicit_flattening() call, causing nested fields to be truncated and interaction dates to be missing entirely.
  • CLI: list export --check-unreplied-emails now works standalone without requiring --expand.
  • CLI: expand: ["interactionDates"] now always produces 8 canonical columns regardless of data presence, ensuring consistent schema across all records.
  • SDK: Improved error message when adding wrong entity type to a list (e.g., adding a company to a person list). Now provides clear guidance about list type requirements instead of exposing raw API validation error.

Performance

  • CLI: Query expand: ["interactionDates"] is significantly faster with parallel person resolution, bounded concurrent fetches (semaphore=10), and parallelized section resolution. Default concurrency increased from 5 to 15 (tunable via XAFFINITY_QUERY_CONCURRENCY).

0.9.12 - 2026-01-17

Changed

  • CLI: Query listEntries now normalizes reference field values to display strings:
  • Person fields: {"firstName": "Jane", "lastName": "Doe"} → "Jane Doe"
  • Company fields: {"name": "Acme Corp", "id": 123} → "Acme Corp"
  • Multi-person/company fields: Arrays of names instead of raw objects
  • Use expand or include for full entity data when needed
  • CLI: Query output with explicit select on fields.* paths now flattens fields to top-level columns in table/CSV/markdown formats. JSON output preserves nested structure.

0.9.11 - 2026-01-17

Added

  • CLI: Query include clause now displays included relationships inline by default. Use --include-style=separate for separate tables or --include-style=ids-only for raw IDs. JSON output includes full included and included_by_parent mappings for correlation.
  • CLI: Query include extended syntax for custom display fields: include: {companies: {display: ["name", "domain"]}}.
  • SDK: with_interaction_dates and with_interaction_persons parameters for CompanyService.get() and PersonService.get(). When enabled, routes to V1 API to fetch interaction date summaries (last/next meeting, email dates, team member IDs).
  • CLI: --expand interactions option for list export command. Adds interaction date summaries to each list entry (last meeting, next meeting, last email, last interaction with daysSince/daysUntil calculations and team member names). Supports both JSON and CSV output formats.
  • CLI: expand: ["interactionDates"] support in query command. Enriches records with interaction date summaries directly on each record in result.data. Works with persons, companies, and listEntries queries.
  • CLI: --check-unreplied-emails flag for list export command. Detects unreplied incoming emails for each list entry and adds date, daysSince, and subject to output. Use --unreplied-lookback-days to configure lookback period (default: 30 days).
  • CLI: --with-interaction-dates and --with-interaction-persons flags for company get and person get commands. Fetches interaction date summaries directly in entity get operations.
  • MCP: Query tool format parameter now functional (was previously ignored). Supports toon, markdown, json, jsonl, csv.
  • CLI: --max-output-bytes option for query command. Enables format-aware truncation for MCP use, returning exit code 100 when truncated.
  • CLI: format_toon_envelope() function for TOON output with full envelope (data[N]{...}:, pagination:, included_*: sections).
  • SDK: Batch association methods for PersonService, CompanyService, and OpportunityService:
  • get_associated_company_ids_batch(person_ids) / get_associated_opportunity_ids_batch(person_ids)
  • get_associated_person_ids_batch(company_ids) / get_associated_opportunity_ids_batch(company_ids)
  • get_associated_person_ids_batch(opportunity_ids) / get_associated_company_ids_batch(opportunity_ids)
  • All return dict[EntityId, list[AssocId]] with on_error="raise"|"skip" parameter.
  • SDK: retries parameter on persons.get(), companies.get(), and opportunities.get() methods. Enables automatic retry with exponential backoff on 404 errors to handle V1→V2 eventual consistency after create operations. Default is retries=0 (fail fast).
  • CLI: Reminder date options now accept relative dates and keywords in addition to ISO-8601:
  • --due-date: +7d, +2w, +1m, +1y, today, tomorrow, yesterday, now
  • --due-after, --due-before: Same formats for filtering in reminder ls
  • Example: xaffinity reminder create --due-date +7d --type one-time --owner-id 123
  • CLI: Domain validation for company create and company update now provides helpful error messages:
  • Detects underscores (RFC 1035 violation) and suggests dash replacement
  • Detects URL prefixes and extracts domain
  • Example: --domain test_company.com → "Use 'test-company.com' instead"
  • Docs: V1→V2 eventual consistency guide covering 404 after create and stale data after update scenarios.
  • Tests: Integration test suite for SDK write operations (tests/integration/).

Changed

  • SDK: OpportunityService.get_associated_people() and get_associated_companies() now use V2 batch lookup instead of individual V1 fetches, reducing N+1 API calls (e.g., 50 people now fetched in 2 calls instead of 51).
  • SDK: Query executor _batch_fetch_by_ids() now uses V2 batch lookup for persons and companies, improving query performance on relationship includes.
  • CLI: Query include clause now fetches relationship IDs in parallel, then batch-fetches full records via V2 API, reducing API calls from N×M to N+1 for deduped lookups.

Changed (Breaking)

  • MCP: Query tool default format changed from json to toon for better token efficiency (~40% fewer tokens).
  • CLI: TOON query output now includes full envelope structure instead of data-only format. The data prefix is added to the array header: data[N]{fields}: instead of [N]{fields}:.

Fixed

  • MCP: Query tool now honors the format parameter instead of always using JSON output.
  • SDK: ListEntryService.batch_update_fields() now uses correct V2 API payload format. Previously failed with "Missing discriminator for property operation" error.

0.9.9 - 2026-01-14

Added

  • CLI: query command now supports advanced relationship filtering:
  • all quantifier: Filter where all related items match a condition (e.g., find persons where all their companies have ".com" domains)
  • none quantifier: Filter where no related items match a condition (e.g., find persons with no spam interactions)
  • exists subquery: Filter where at least one related item exists, optionally matching a condition (e.g., find persons who have email interactions)
  • _count pseudo-field: Filter by count of related items (e.g., "path": "companies._count", "op": "gte", "value": 2)
  • Available relationship paths: persons→companies/opportunities/interactions/notes/listEntries, companies→persons/opportunities/interactions/notes/listEntries, opportunities→persons/companies/interactions
  • Note: These features cause N+1 API calls to fetch relationship data; use --dry-run to preview

Changed (Breaking)

  • CLI: Renamed relationship "people" to "persons" for consistency with entity type names:
  • Query include: {"from": "companies", "include": ["people"]} → use ["persons"]
  • CLI --expand: xaffinity company get <id> --expand people → use --expand persons
  • JSON output: data.people → data.persons

Fixed

  • CLI: Query engine no longer silently passes all records for all, none, and _count filters. Previously these were placeholder implementations that returned True for all records, causing incorrect query results. (Bug #15)

0.9.8 - 2026-01-12

Fixed

  • CLI: query command now correctly fetches all records before applying filter, sort, or aggregate operations. Previously, limits were applied during fetch which caused incorrect results:
  • With filters: Empty results when matching records were beyond the limit position
  • With sort + limit: Random N records sorted instead of actual top N
  • With aggregate: Inaccurate counts/sums computed on partial data

0.9.7 - 2026-01-12

Fixed

  • CI: Smoke test now correctly installs CLI extras before testing CLI import

0.9.6 - 2026-01-12

Added

  • CLI: listEntries queries now include convenience aliases: listEntryId, entityId, entityName, entityType. These intuitive field names work in both select and where clauses.
  • CLI: listEntries records now always include a fields key (defaults to {} if no custom fields).

Changed

  • CLI: Query projection now includes null values for explicitly selected fields. Previously, select: ["entityName", "fields.Status"] would return {} if Status was null; now returns {"entityName": "Acme", "fields": {"Status": null}}.

0.9.5 - 2026-01-12

Added

  • CLI: New --output/-o option supporting multiple formats: json, jsonl, markdown, toon, csv, table (default).
  • markdown: GitHub-flavored markdown tables, best for LLM analysis and comprehension
  • toon: Token-Optimized Object Notation, 30-60% fewer tokens than JSON for large datasets
  • jsonl: JSON Lines format, one object per line for streaming workflows
  • Example: xaffinity person ls --output markdown, xaffinity query -o toon
  • Existing --csv and --json flags continue to work as before.

Changed

  • CLI: to_cell() now extracts "text" from dropdown/multi-select fields instead of JSON-serializing the full dict. This makes CSV and other tabular outputs human-readable for dropdown values.

Fixed

  • CLI: query command with limit now correctly returns results when combined with client-side filters (like has_any on multi-select fields). Previously, the limit was applied during fetch before filtering, causing empty results when the first N records didn't match the filter criteria.

0.9.4 - 2026-01-12

Added

  • CLI: query command now supports has_any and has_all operators for multi-select field filtering.
  • SDK/CLI: Filter parser now supports V2 API comparison operators: >, >=, <, <= for numeric/date comparisons.
  • SDK/CLI: Filter parser now supports word-based operator aliases for LLM/human clarity:
  • contains, starts_with, ends_with (string matching)
  • gt, gte, lt, lte (numeric/date comparisons)
  • is null, is not null, is empty (null/empty checks)
  • SDK/CLI: Filter parser now supports collection bracket syntax [A, B, C] with operators:
  • in [A, B] - value is one of the listed values
  • between [1, 10] - value is in range (inclusive)
  • has_any [A, B] - array field contains any of the values
  • has_all [A, B] - array field contains all of the values
  • contains_any [A, B] - substring match for any term
  • contains_all [A, B] - substring match for all terms
  • = [A, B] - set equality (array has exactly these elements)
  • =~ [A, B] - V2 API collection contains (array contains all elements)

Fixed

  • CLI: query command now correctly filters on multi-select dropdown fields (like "Team Member"). The eq operator checks array membership for scalar values and set equality for array values. Previously, these queries returned 0 results due to strict equality comparison.
  • SDK/CLI: list export --filter now correctly matches multi-select dropdown fields. The =, !=, and =~ operators now handle array values properly. Also fixes extraction of text values from multi-select dropdown API responses.
  • SDK/CLI: Fixed =^ (starts_with) and =$ (ends_with) operators which were broken due to tokenizer ordering issue.

Improved

  • SDK/CLI: Filter parser now provides helpful hints for common mistakes:
  • Multi-word field names: suggests quoting ("Team Member")
  • Multi-word values: suggests quoting ("Intro Meeting")
  • SQL keywords (AND, OR): suggests correct symbols (&, |)
  • Double equals (==): suggests single =

0.9.3 - 2026-01-11

Changed

  • CI: SDK releases now include MCPB bundle and plugin ZIP for convenience.
  • CI: Enabled PyPI attestations via workflow_dispatch API trigger.

0.9.2 - 2026-01-11

Fixed

  • SDK: AsyncListEntryService.pages() now supports progress_callback parameter (sync/async parity fix).

Changed

  • BREAKING: CLI: interaction ls JSON output restructured for consistency:
  • .data.interactions → .data (direct array)
  • .data.metadata.totalRows → .meta.summary.totalRows
  • .data.metadata.dateRange → .meta.summary.dateRange
  • .data.metadata.typeStats → .meta.summary.typeBreakdown
  • BREAKING: CLI: note ls JSON output restructured for consistency:
  • .data.notes → .data (direct array)
  • Pagination: .data.notes.nextCursor → .meta.pagination.nextCursor
  • BREAKING: CLI: query JSON output (with --include-meta) restructured for consistency:
  • .meta.recordCount → .meta.summary.totalRows
  • Included entity counts now in .meta.summary.includedCounts
  • CLI: Standardized ResultSummary footer rendering across all commands (displays row counts, date ranges, type breakdowns as compact footer text instead of tables).

0.9.1 - 2026-01-11

Added

  • CLI: query command now validates entity queryability and provides clear error messages for unsupported entities.
  • CLI: query command resolves field names to IDs automatically (e.g., "field": "Status" works alongside "fieldId": 123).

Fixed

  • CLI: query for listEntries entity now correctly requires listId filter.
  • CLI: query relationship definitions now correctly set requires_n_plus_1 flag for proper query planning.

0.9.0 - 2026-01-11

Added

  • CLI: New query command for structured JSON queries with complex filtering, includes, aggregations, and sorting. Use --dry-run to preview execution plans. Supports entities: persons, companies, opportunities, listEntries, interactions, notes.
  • MCP: New query tool for complex data queries via JSON query language. Supports filtering (AND/OR/NOT), includes (related entities), aggregations (count/sum/avg/min/max), groupBy, and sorting.
  • CLI: --limit alias for --max-results on company get, person get, and opportunity get commands (consistency with ls commands).

Changed

  • CLI: --list, --list-entry-field, and --show-list-entry-fields now auto-imply --expand list-entries on company get and person get commands (improved DX).

0.8.6 - 2026-01-10

Added

  • SDK: PersonService.get_associated_company_ids() and get_associated_opportunity_ids() methods for symmetric association API.
  • SDK: CompanyService.get_associated_opportunity_ids() method.

0.8.5 - 2026-01-10

Fixed

  • CLI/SDK: FilterParseError now raised when filter expressions fail to parse (previously silently ignored). Common cause: unquoted multi-word values like --filter 'Status=Intro Meeting' must be quoted: --filter 'Status="Intro Meeting"'.
  • CLI: Pre-commit hook now validates installed CLI version matches pyproject.toml before regenerating MCP registry.

0.8.4 - 2026-01-10

Added

  • CLI: NDJSON progress output for interaction ls multi-type queries (MCP integration).

0.8.3 - 2026-01-10

Added

  • CLI: --limit alias for --max-results on all ls commands (LLM-friendly).
  • CLI: Option aliases now included in --help --json output.

0.8.2 - 2026-01-10

No user-facing changes. Version bump for PyPI release.

0.8.1 - 2026-01-10

No user-facing changes. Version bump for PyPI release.

0.8.0 - 2026-01-10

Changed

  • CLI: Renamed --json to --set-json on person field, company field, opportunity field commands to avoid conflict with global --json output flag.
  • BREAKING: CLI: --csv FILE is now --csv flag that outputs CSV to stdout. Use shell redirection: --csv > file.csv. Applies to person ls, company ls, opportunity ls, list export.
  • CLI: --csv and --json are now mutually exclusive (error if both specified).
  • CLI: person ls --query and company ls --query now support --field and --field-type options via hybrid V1→V2 fetch.
  • BREAKING: CLI: interaction ls date filter parameters renamed for consistency:
  • --start-time → --after
  • --end-time → --before
  • Context metadata keys changed: startTime/endTime → after/before
  • Note: Interaction object fields (startTime/endTime) are unchanged
  • BREAKING: CLI: interaction ls now requires --type (was optional but API required it).
  • CLI: interaction ls date range now defaults to all-time when --days and --after are omitted.
  • BREAKING: CLI: interaction ls removed --cursor and --all flags (auto-chunking replaces manual pagination).
  • BREAKING: CLI: interaction ls output field renamed modifiers.type → modifiers.types (now always an array).
  • BREAKING: CLI: interaction ls metadata chunksProcessed moved to typeStats[type].chunksProcessed.
  • BREAKING: CLI: Naive datetime strings (without timezone) are now interpreted as local time instead of UTC. Use explicit Z suffix or offset for UTC. See datetime-handling guide for details.
  • BREAKING: CLI: List entry field commands unified into single entry field command:
  • entry set-field --field F --value V → entry field --set F V
  • entry set-field --field F --value-json '{...}' → entry field --set-json '{"F": ...}'
  • entry set-fields --updates-json '{...}' → entry field --set-json '{...}'
  • entry unset-field --field F → entry field --unset F
  • entry unset-field --field F --value V → entry field --unset-value F V
  • entry unset-field --field F --all-values → entry field --unset F
  • Removed: --field-id option (field IDs can be passed as FIELD argument directly)
  • Behavior change: --set on multi-value field now replaces all values (use --append to add)

Added

  • CLI: interaction ls --type now accepts multiple types (e.g., --type email --type meeting).
  • CLI: interaction ls --type all convenience option fetches all interaction types.
  • CLI: interaction ls multi-type results sorted by date descending (types interleaved).
  • CLI: interaction ls metadata includes typeStats with per-type counts and chunk info.
  • CLI: interaction ls auto-chunking for date ranges > 1 year (transparently splits into API-compatible chunks).
  • CLI: interaction ls --days N convenience flag for "last N days" queries.
  • CLI: interaction ls --csv and --csv-bom flags for CSV export.
  • CLI: interaction ls metadata in JSON output includes dateRange, typeStats, totalRows.
  • SDK: ListEntryService.from_saved_view() now accepts field_ids and field_types parameters.
  • CLI: list export --saved-view can now be combined with --field for server-side filtering with explicit field selection.
  • SDK: ids parameter added to PersonService, CompanyService, and OpportunityService for batch fetching by ID.
  • CLI: entry field --get FIELD for reading field values (new functionality).
  • CLI: entry field --append FIELD VALUE for adding to multi-value fields without replacing.
  • CLI: entry field --unset-value FIELD VALUE for removing specific value from multi-value field.

Fixed

  • SDK: FieldValues now properly parses field arrays from API responses (previously showed requested=False).

Removed

  • CLI: person search and company search commands. Use person ls --query and company ls --query instead.
  • CLI: Removed entry set-field, entry set-fields, and entry unset-field commands (replaced by unified entry field).

0.7.0 - 2026-01-08

Added

  • CLI: Column limiting for wide table output - tables now auto-limit columns based on terminal width.
  • CLI: --all-columns flag to show all columns regardless of terminal width.
  • CLI: --max-columns N flag for fine control over column limits.
  • CLI: Real-time filter scanning progress during list export --filter shows "Scanning X... (Y matches)".
  • CLI: Export summary line after filtered operations (e.g., "Exported 35 rows (filtered from 9,340 scanned) in 2:15").
  • CLI: format_duration() helper for human-readable time formatting.
  • CLI: Rich pager now uses styles=True to preserve ANSI colors when paging.
  • SDK: FilterStats dataclass for tracking scanned/matched counts during filtered pagination.
  • SDK: PaginatedResponse.filter_stats property exposes filter statistics.

Fixed

  • CLI: JSON progress output no longer appears alongside Rich progress bar (mutual exclusivity enforced).
  • SDK: Dropdown field filtering now extracts "text" property from dropdown dicts.

0.6.11 - 2026-01-07

Added

  • CLI: Parameter help text (help) now included in --help --json output.
  • CLI: Click.Choice values (choices) now included in --help --json output.
  • CLI: Examples from docstrings now parsed and included in --help --json output.

Changed

  • CLI: Improved --filter help text with full operator list (= != =~ =^ =$ > < >= <=).
  • CLI: Improved --query help text to clarify V1 fuzzy search vs V2 structured filtering.

0.6.10 - 2026-01-06

Added

  • CLI: JSON progress output to stderr when not connected to a TTY (for MCP integration).
  • CLI: @progress_capable decorator to mark commands supporting progress reporting.
  • CLI: Rate-limited progress updates (0.65s interval) with guaranteed 100% completion emission.
  • CLI: progressCapable field in --help --json output for registry generation.
  • MCP: run_xaffinity_with_progress helper for progress-aware CLI execution.
  • MCP: command_supports_progress helper to check registry for progress capability.
  • MCP: Execute tools now forward CLI progress for @progress_capable commands.
  • CLI: File upload commands (person/company/opportunity files upload) marked as @progress_capable.
  • MCP: PROGRESS_MIN_VERSION in COMPATIBILITY for graceful degradation with older CLIs.
  • MCP: version_gte helper for portable version comparison (macOS/Linux).
  • MCP: CLI version check in command_supports_progress (disables progress for CLI < 0.6.10).
  • MCP: XAFFINITY_CLI_VERSION exported for tool scripts to access CLI version.

Changed

  • MCP: Updated mcp-bash.lock to patched v0.9.3 (commit ee245a7) with progress passthrough fixes.

0.6.9 - 2026-01-06

Changed

  • CLI Plugin: Skill now documents destructive command confirmation flow (look up, ask, wait, execute with --yes).
  • CLI Plugin: Skill lists all destructive commands requiring double confirmation.

0.6.8 - 2026-01-05

Added

  • CLI: @category and @destructive decorators for MCP registry generation.
  • CLI: --help --json output for machine-readable command documentation.
  • CLI: Commands now expose category (read/write/local) and destructive metadata.

0.6.7 - 2026-01-03

Changed

  • Docs: Updated all documentation links to use versioned /latest/ URLs for reliable navigation.

0.6.6 - 2026-01-03

Fixed

  • SDK: Regex patterns in lists.py and http.py were double-escaped, matching literal \d instead of digits.

0.6.5 - 2026-01-02

No user-facing changes. Version bump for PyPI release.

0.6.4 - 2026-01-02

No user-facing changes. Version bump for PyPI release.

0.6.3 - 2026-01-02

No user-facing changes. Version bump for PyPI release.

0.6.2 - 2026-01-02

Fixed

  • CLI: Added explicit type=str to Click arguments for Python 3.13 mypy compatibility.

0.6.1 - 2026-01-02

Changed

  • Docs: Separated MCP Server documentation from Claude Code plugins.

0.6.0 - 2026-01-02

Added

  • MCP: read-xaffinity-resource tool for clients with limited resource support.

Changed

  • Plugins: Restructured into 3-plugin marketplace architecture (affinity-sdk, xaffinity-cli, xaffinity-mcp).
  • Docs: Restructured Claude integrations with consistent naming.

Fixed

  • CLI: Improved setup-key command UX with Rich styling.
  • MCP: Source both .zprofile and .zshrc in environment wrapper.
  • MCP: Parse JSON response correctly for check-key output.

0.5.1 - 2026-01-01

Fixed

  • Plugins: Consolidated plugin structure and fixed relative paths.

0.5.0 - 2026-01-01

Added

  • MCP: Initial xaffinity MCP server as separate Claude Code plugin.
  • CLI: Top-level entry command group as shorthand for list entry (e.g., xaffinity entry get instead of xaffinity list entry get).
  • CLI: --query / -q flag for person ls, company ls, and opportunity ls to enable free-text search (V1 API).
  • CLI: --company-id and --opportunity-id options for interaction ls.
  • CLI: -A short flag for --all on all paginated list commands.
  • CLI: -n short flag for --max-results on all commands with result limits.
  • CLI: -s short flag for --page-size on all pagination commands.
  • CLI: -t short flag for --type on interaction commands.
  • CLI: Structured CommandContext for all commands.
  • SDK: OpportunityService.search(), search_pages(), search_all() methods for V1 opportunity search.
  • SDK: Async versions of opportunity search methods in AsyncOpportunityService.
  • SDK: InteractionService.list() now accepts company_id and opportunity_id parameters.

Changed

  • CLI: list view renamed to list get for consistency with other entity commands.
  • CLI: --completed/--not-completed boolean flag pattern for reminder update (replaces separate flags).
  • CLI: Removed API version mentions from help text (implementation detail).
  • CLI: interaction ls now requires an entity ID (--person-id, --company-id, or --opportunity-id) and defaults to last 7 days with visible warning (API max: 1 year).
  • CLI: Unified person field, company field, opportunity field commands replace set-field, set-fields, and unset-field commands. New syntax: --set FIELD VALUE, --unset FIELD, --set-json '{...}', --get FIELD.
  • CLI: Note content separated from metadata in table display.

Removed

  • CLI: person set-field, person set-fields, person unset-field commands (use person field instead).
  • CLI: company set-field, company set-fields, company unset-field commands (use company field instead).
  • CLI: opportunity set-field, opportunity set-fields, opportunity unset-field commands (use opportunity field instead).

Fixed

  • CLI: Help text formatting - added missing spaces in command examples (~78 instances).
  • CLI: Improved --cursor help text explaining incompatibility with --page-size.
  • CLI: Clarified --csv help text to indicate it writes to file while stdout format is unchanged.
  • CLI: CommandContext validation and test isolation issues.

0.4.8 - 2025-12-31

Added

  • CLI: xaffinity field history for viewing field value change history.
  • CLI: Session caching for pipeline optimization via AFFINITY_SESSION_CACHE environment variable.
  • CLI: session start/end/status commands for managing session cache lifecycle.
  • CLI: --session-cache and --no-cache global flags for cache control.
  • CLI: Cache hit/miss visibility with --trace flag.
  • CLI: config check-key --json now includes pattern field showing key source.
  • SDK: Client-side filtering for list entries (V2 API does not support server-side filtering).

Changed

  • CLI: --filter on list entry commands now applies client-side with warning (V2 API limitation).
  • CLI: Removed --opportunity-id from list entry add (opportunities are created atomically via opportunity create --list-id).

Fixed

  • SDK: Client-side filter parsing handles whitespace-only and unparseable filters gracefully.
  • CLI: --filter on list entries now returns proper field values (V2 API format).

0.4.0 - 2025-12-30

Added

  • CLI: config check-key command to check if an API key is configured (checks environment, .env, and config.toml).
  • CLI: config setup-key command for secure API key configuration with hidden input, validation, and automatic .gitignore management.
  • CLI: set-field, set-fields, unset-field commands for person, company, opportunity, and list entry entities.
  • CLI: list entry get command with field metadata display.
  • CLI: Enhanced --expand-filter syntax with OR (|), AND (&), NOT (!), NULL checks (=*, !=*), and contains (=~).
  • SDK: list_entries field added to Person model.
  • SDK: Unified filter parser with parse() function and matches() method for client-side filter evaluation.

Changed

  • CLI: Authentication error hints now reference config check-key and config setup-key commands.
  • CLI: Authentication documentation updated with Quick Setup section.

Fixed

  • CLI: Default --page-size reduced from 200 to 100 to match Affinity API limit.
  • SDK: Async merge() parameter names corrected (primaryCompanyId/duplicateCompanyId).
  • SDK: Cache invalidation added to async create/update/delete in CompanyService.

Removed

  • CLI: Deprecated field-value and field-value-changes command groups removed (use entity-specific field commands instead).
  • CLI: Deprecated update-field and batch-update list entry commands removed (use set-field/set-fields instead).

0.3.0 - 2025-12-30

Added

  • CLI: xaffinity list export --expand for exporting list entries with entity field expansion (company/person/opportunity fields).
  • CLI: xaffinity field-value-changes ls for viewing field value change history.
  • CLI: xaffinity company get (id/URL/resolver selectors) with --all-fields and --expand lists|list-entries|people.
  • CLI: xaffinity person get (id/URL/resolver selectors) with --all-fields and --expand lists|list-entries.
  • CLI: xaffinity person ls and xaffinity company ls with search flags.
  • CLI: xaffinity opportunity command group with ls/get/create/update/delete.
  • CLI: xaffinity note, xaffinity reminder, and xaffinity interaction command groups.
  • CLI: xaffinity file upload command for file uploads.
  • CLI: Write/merge/field operations for list entries.
  • CLI: --max-results and --all controls for pagination and expansions.
  • CLI: Progress reporting for all paginated commands.
  • CLI: Rate limit visibility via SDK event hook.
  • CLI: --trace flag for debugging SDK requests.
  • SDK: client.files.download_stream_with_info(...) exposes headers/filename/size alongside streamed bytes.
  • SDK: v1-only company association helpers get_associated_person_ids(...) and get_associated_people(...).
  • SDK: List-scoped opportunity resolution helpers resolve(...) and resolve_all(...).
  • SDK: Async parity for company and person services.
  • SDK: Async parity for V1-only services.
  • SDK: Async list and list entry write helpers.
  • SDK: Pagination support for person resolution in PersonService and AsyncPersonService.
  • SDK: client.clear_cache() method for cache invalidation.
  • SDK: Field value changes service with client.field_value_changes.
  • SDK: Detailed exception handling for ConflictError, UnsafeUrlError, and UnsupportedOperationError.
  • SDK: Webhook sent_at timestamp validation.
  • SDK: Request pipeline with policies (read-only mode, transport injection).
  • SDK: on_error hook for error observability.
  • Inbound webhook parsing helpers: parse_webhook(...), dispatch_webhook(...), and BodyRegistry.
  • Claude Code plugin for SDK/CLI documentation and guidance.

Changed

  • CLI: Enum fields now display human-readable names instead of integers (type, status, direction, actionType).
  • CLI: Datetimes render in local time with timezone info in column headers.
  • CLI: Human/table output renders dict-shaped results as sections/tables (no JSON-looking panels).
  • CLI: --json output now uses section-keyed data and meta.pagination.
  • CLI: List-entry fields tables default to list-only fields; use --list-entry-fields-scope all for full payloads.
  • CLI: Domain columns are now linkified in table output.
  • CLI: Output only pages when content would scroll.
  • FieldValueType is now V2-first and string-based (e.g. dropdown-multi, ranked-dropdown, interaction).
  • ListEntry.entity is now discriminated by entity_type.
  • Rate limit API unified across sync and async clients.

Fixed

  • SDK: ListService.get() now uses V1 API to return correct list_size.
  • CLI: JSON serialization now handles datetime objects correctly.
  • Sync entity file download deadline_seconds handling.
  • File downloads now use public services for company expansion pagination.

0.2.0 - 2025-12-17

Added

  • Initial public release.
  • client.files.download_stream(...) and client.files.download_to(...) for chunked file downloads.
  • client.files.upload_path(...) and client.files.upload_bytes(...) for ergonomic uploads.
  • client.files.all(...) / client.files.iter(...) for auto-pagination over files.

Changed

  • File downloads now follow redirects without forwarding credentials and use the standard retry/diagnostics policy.
  • client.files.list(...) and client.files.upload(...) now require exactly one of person_id, organization_id, or opportunity_id (per API contract).