Records
Records are the CRM's companies, people, deals and activities. Every record command takes --type company, --type person, --type deal or --type activity.
The ID after the verb is the record you're working on. record get, record update and record delete take one or more IDs of one type, each listed once, and record create takes none. The company, person and deal fields point to related records, such as the company a deal belongs to.
Find records
sofatofu record search --type person --filter '{"company":"k7m2p9qx4dz1"}' --limit 200 --offset 0
Matches come back in items, most recently updated first. For the filter syntax, see Filters and pipeline queries. Each page has its own read_id and reads current data. To get the next page, repeat the same search with --offset set to next.offset. A write based on a search needs every target on one page, so use --limit 200 when you search before a write.
To get just the number of matches, add --count. Counts have no limit. Search leaves out deleted records by default. Add --include deleted to get active and deleted records together. A deleted record has a deleted_at.
An activity is one entry in a record's history. To read a record's history with the most recent event first, search activities filtered by that record, and order them by occurred_at, which only activities accept:
sofatofu record search --type activity --filter '{"deal":"k7m2p9qx4dz1","occurred_at":{"gte":"2026-09-01T00:00:00Z","lte":"2026-09-30T23:59:59Z"}}' --order-by occurred_at --limit 200
If a record has more than 200 activities, narrow the occurred_at range.
Read records by ID
sofatofu record get k7m2p9qx4dz1 b3n8r2wq7yt5 --type company
The records come back under records in the order you gave the IDs, with a single read_id for all of them. record get returns no activity history; use the activity search above.
If an ID doesn't exist or belongs to a deleted record, the read fails with not_found. Add --include deleted to read deleted records. They carry deleted_at, and their change_id names the deletion. To restore one, see Reverse a change.
Each record includes its id, its built-in fields, its custom fields, created_at and updated_at. It also includes change_id while that change is still in history. Values that were never set are left out, while false, 0 and empty strings are present. Archived custom fields never appear. Deals show value as a number and stage as a name.
Create records
record create makes records of one type in a single change. Pass --records one JSON array, where each object holds fields from the table below and no ID. Either every record is created or none is.
Search before creating records, with the filter from The search before a create. Create only records that are missing.
sofatofu record search --type company --filter '{"domain":{"in":["acme.example.com","bolt.example.com"]}}' --include deleted --limit 200
sofatofu record create --type company --records '[{"name":"Acme","domain":"acme.example.com"},{"name":"Bolt","domain":"bolt.example.com","fields":{"Region":"EMEA"}}]' --reason "Add the leads from the fair" --read-id="<read_id>"
The result returns ids in the order you gave the records, plus effect.created. If the write is refused, the error tells you what to fix:
duplicatemeans an item has the strict key of another record or of an earlier item. The error gives the item'sindex. For a stored record it also givesexisting_id: update that record instead of creating the item. For an earlier item it givesexisting_index: send each key once. The read stays usable.possible_duplicatemeans items have a loose match.itemslists every failing item in input order, with itsindexandcount, the number of matches.existing_idslists up to 20 matching records, most recently updated first.existing_indexeslists other items of this create with the same match. On an update, an item names a target byid, andexisting_idsends with the other targets with the same match. Follow thepossible_duplicaterule in When a command fails.--forceapplies to every item of the write. Change history does not show it, so--reasonis the only record of your decision.deletedmeans a deleted record holds the strict key of an item. The error gives the item'sindex, the deleted record'sid, and the deletion'schange_id. Restore that record instead of creating a replacement, as Reverse a change describes.invalid_inputmeans something in the request is wrong. Fix what the message names. If the error includes anindex, it points to an item in--records. Another error can show up once you've fixed the first.
Update records
record update applies one patch to records of one type in a single change. Pass the IDs as positional arguments, and pass --patch one JSON object with at least one field from the table below. Either every target is updated or none is.
sofatofu record search --type deal --filter '{"company":"h6j3k8l2m9n4","stage":"in_progress"}' --limit 200
sofatofu record update k7m2p9qx4dz1 b3n8r2wq7yt5 --type deal --patch '{"stage":"won","owner":null,"fields":{"Priority":"high"}}' --reason "Contracts signed" --read-id="<read_id>"
The result returns effect.updated, the number of records updated. Every update is recorded as a change, even when no value changes, so don't repeat an update to check that it worked.
Omitted fields keep their values, and only JSON null clears one. Inside patch.fields, a named key replaces that custom value, null clears it, and every key you leave out keeps its value on that record. That includes values stored under archived fields.
These patches are rejected: an empty object, an unknown key, or null on a required field such as name. Only some fields can be cleared:
| Type | Fields you can clear |
|---|---|
company |
domain |
person |
email, company |
deal |
value, owner, expected_close_date |
activity |
person, company, deal, subject, body |
An activity must keep at least one person, company or deal link.
Updates cannot give two records the same strict key. For a loose match, see possible_duplicate in Create records.
duplicate:idnames a target whose key would change.existing_idnames the other record, which can also be a target of this update. Inspect both before retrying.deleted: a deleted record holds the requested key.idnames that deleted record, andchange_idnames its deletion.
Delete records
sofatofu record search --type person --filter '{"company":"k7m2p9qx4dz1"}' --limit 200
sofatofu record delete b3n8r2wq7yt5 c4d8e2f6g0h3 --type person --reason "Remove confirmed duplicates" --read-id="<read_id>"
Choose the IDs that fit the request's deletion criteria. When the criteria identify the targets, don't ask for approval of each ID.
Deleting marks records as deleted. You can reverse the change to restore them within 30 days. When you report a delete, give the count and the change_id.
A linked error means active records still refer to a target. It names up to 20 of them. If the request covers those changes, relink each blocking record with record update and a patch that names another record, or delete it. Read each blocking record before you write to it, then read the targets again. If clearing the error would need changes beyond the request, ask about those changes only.
Log an activity
Use a kind of call, meeting, email or note, and link the activity to at least one person, company or deal.
Search for the event before you log it, with the activity filter from The search before a create:
sofatofu record search --type activity --filter '{"kind":"call","occurred_at":"2026-09-22T10:00:00Z","person":null,"company":null,"deal":"k7m2p9qx4dz1"}' --limit 200
sofatofu record create --type activity --records '[{"kind":"call","occurred_at":"2026-09-22T10:00:00Z","deal":"k7m2p9qx4dz1","subject":"Pricing call"}]' --reason "Record the pricing discussion" --read-id="<read_id>"
If you don't know whether a create worked, search again before retrying. If you find the activity, use its ID instead of creating it again. If the first create succeeded, repeating it with the same kind, occurred_at and links gets possible_duplicate, even if subject or body differs. A retry with another kind, time or link gets no possible_duplicate and creates a second activity.
Fields and matching
A strict key can belong to at most one record in a workspace. A deleted record keeps its key until permanent removal after 30 days. A record without a value for its strict-key field has no strict key. A loose match is another active record of the same type with equal values in every loose-match field. Names compare without case and extra spaces. An empty loose-match field prevents a loose match, but a missing person, company or deal of an activity matches the same missing field. A deleted record never counts. A create, or an update that changes a loose-match field, gets possible_duplicate unless it has --force. A change of only case or spaces does not count. --force never skips a strict key. Other fields can repeat.
| Type | Required on create | Also accepts | Strict key | Loose match |
|---|---|---|---|---|
company |
name |
domain |
domain, without one leading www. |
name |
person |
name |
email, company |
email |
name and company |
deal |
name, company |
stage, value, owner, expected_close_date |
None | name and company |
activity |
kind, occurred_at, and at least one of person, company, deal |
subject, body, and more than one of person, company, deal |
None | kind, occurred_at, person, company and deal |
Every type also accepts fields, a JSON object whose keys are active field names from workspace get. A select field takes an option name. How to fill them in:
- Links. Pass record IDs to
company,personanddeal. Pass a memberidfrommember listtoowner. A deal keeps its owner after that member leaves the workspace. - Stage. Pass an active stage name. The defaults are
lead,in_progress,wonandlost. A new deal without astagegets the first active stage. Checkworkspace getfor the workspace's own stages. - Value. Pass a JSON number of 0 or more, such as
1200.5. Unrealistically large values lose exactness. Value filters take the same format. Set the workspace currency before you write a deal value. - Dates. Use
YYYY-MM-DDforexpected_close_date. Use an ISO 8601 date-time such as2026-09-22T10:00:00Zforoccurred_at, ending inZor an offset. A date-time with no offset, a bare date, or a phrase liketomorrowis rejected. - Domain and email. Send a
domainas a bare name such asacme.example.com, with nohttps://, port or trailing dot. Send anemailas a plain address such as[email protected].
Edge cases and formatting details
- Text. Text keeps its case. Sofatofu trims leading and trailing whitespace and replaces each run of whitespace, including tabs and newlines, with one space. Activity bodies lose their line breaks. Matching on text ignores case.
- Length limits. A
name,stage,subjector custom text value holds 1 to 255 characters. Abodyholds 1 to 4000. Emoji are fine. - Hidden characters. Text with a hidden or direction-control character, such as U+200B or U+202E, returns
invalid_input. So does NUL (U+0000) or half of a character pair anywhere in a request. The error names the field and any hidden character. - Email case. Reads return an email in lowercase.
- Domains in other alphabets. Reads return these in their ASCII form, so
пример.рфcomes back asxn--e1afmkfd.xn--p1ai. A search finds the domain by either form. A domain label (a part between dots) that mixes Latin letters with letters from another alphabet is rejected. This also applies to the domain part of an email. - Leading
www.. Reads return a domain with its leadingwww.. Sofatofu ignores one leadingwww.only to compare domains, and only when two or more labels remain, sowww.commatches onlywww.com. - Activity kind. Case and surrounding whitespace don't matter. Reads return the kind in lowercase.
