Manage contacts, companies, deals, tasks, and notes in the Twenty CRM. Use when the user asks about CRM data, contacts, leads, companies, opportunities, deals, pipelines, tasks, or notes. Also use for creating, updating, or searching CRM records.
Manage CRM data via Twenty's REST API (local Docker instance).
Load the API key from /opt/ph-infra/.env — same pattern as the Mercury skill. The key works as a regular Bearer token.
# Load token from .env
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
# All curl calls use this pattern:
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/..."
Base URL: http://127.0.0.1:3001 (local Docker — do NOT use the Tailscale URL for API calls)
Twenty's filter syntax uses colon as the key-value separator (not =). Use curl -G --data-urlencode for reliable encoding of % wildcards:
# CORRECT — colon separator + --data-urlencode for wildcards:
curl -s -G -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/companies' \
--data-urlencode 'filter=name[like]:%Acme%'
# CORRECT — simple exact match (no special chars, no encoding needed):
curl -s -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/people?filter=emails.primaryEmail[eq]:[email protected]'
# WRONG (400 error) — equals instead of colon:
# ?filter=name[like]=%25value%25
Operators: [eq], [like], [gte], [lte], [in]
The JSON response wraps data differently depending on the operation — handle each pattern to avoid KeyError:
| Operation | Response path | Example |
|---|---|---|
| List | data.companies | d['data']['companies'] |
| Get by ID | data.company | d['data']['company'] |
| Create | data.createCompany | d['data']['createCompany'] |
| Update | data.updateCompany | d['data']['updateCompany'] |
| Delete | data.deleteCompany | d['data']['deleteCompany'] |
Same pattern for all entities: people/person, opportunities/opportunity, tasks/task, notes/note.
Tip: To handle mutations generically: result = list(d['data'].values())[0]
All SELECT fields require exact enum values — free text will 400. Here are all valid values:
stage (required):
| Value | Label | Color | Use when |
|---|---|---|---|
LEAD | Lead | gray | Initial interest, no conversation yet |
PROSPECT | Prospect | blue | Qualified lead, exploring fit |
DISCOVERY | Discovery | sky | Active discovery calls/meetings |
PROPOSAL | Proposal | yellow | Proposal sent, awaiting decision |
WON | Won | green | Deal closed, not yet started |
ACTIVE | Active | purple | Engagement in progress |
RETAINER | Retainer | orange | Ongoing recurring engagement |
DONE | Done | turquoise | Engagement completed |
LOST | Lost | red | Deal lost |
leadSource:
| Value | Label | Use when |
|---|---|---|
REFERRAL | Referral | Referred by someone (e.g. Vistage, partner intro) |
INBOUND | Inbound | They came to us (website, social, etc.) |
OUTBOUND | Outbound | We reached out to them |
EVENT | Event | Met at a conference/event |
PARTNER | Partner | Came via a partner org |
SOCIAL | Social | Came via social media |
contractType:
| Value | Label | Use when |
|---|---|---|
RETAINER | Retainer | Recurring monthly engagement |
PROJECT | Project | One-time or fixed-duration work |
HYBRID | Hybrid | Mix of retainer + project |
TBD | TBD | Not yet determined |
budgetRange:
UNDER_5K, RANGE_5_15K, RANGE_15_50K, OVER_50K, UNKNOWN
dealTimeline:
IMMEDIATE, THIS_QUARTER, NEXT_QUARTER, EXPLORING
lostReason (use when stage=LOST):
BUDGET, TIMING, COMPETITOR, NO_RESPONSE, BAD_FIT, INTERNAL_CHANGE
companyIndustry:
MEDIA_ENTERTAINMENT, ADVERTISING, ECOMMERCE, SAAS_TECH, AGENCY, CREATOR, NONPROFIT, OTHER
companySizeRange:
SIZE_1_10, SIZE_11_50, SIZE_51_200, SIZE_201_1000, SIZE_1000_PLUS
aiMaturity:
NONE, EXPERIMENTING, ACTIVE_USER, AI_NATIVE
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
# List people
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/people"
# Search by last name (use -G --data-urlencode for wildcard filters)
curl -s -G -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/people' \
--data-urlencode 'filter=name.lastName[like]:%Smith%'
# Get by ID → response: data.person
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/people/{id}"
# Create → response: data.createPerson
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/people" \
-d '{
"name": {"firstName": "Jane", "lastName": "Doe"},
"emails": {"primaryEmail": "[email protected]", "additionalEmails": ["[email protected]"]},
"phones": {"primaryPhoneNumber": "5551234567", "primaryPhoneCountryCode": "US", "primaryPhoneCallingCode": "+1"},
"linkedinLink": {"primaryLinkUrl": "https://linkedin.com/in/janedoe"},
"xLink": {"primaryLinkUrl": "https://x.com/janedoe"},
"city": "Miami",
"jobTitle": "Director",
"companyId": "COMPANY_UUID"
}'
# Update → response: data.updatePerson
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/people/{id}" \
-d '{"companyId": "COMPANY_UUID"}'
# Delete → response: data.deletePerson
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/people/{id}"
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
# List companies
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/companies"
# Search by name
curl -s -G -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:3001/rest/companies' \
--data-urlencode 'filter=name[like]:%Acme%'
# Create → response: data.createCompany
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/companies" \
-d '{
"name": "Acme Corp",
"domainName": {"primaryLinkLabel": "acme.com", "primaryLinkUrl": "https://acme.com"},
"linkedinLink": {"primaryLinkUrl": "https://linkedin.com/company/acme"},
"xLink": {"primaryLinkUrl": "https://x.com/acme"},
"address": {"addressCity": "Miami", "addressState": "Florida", "addressCountry": "United States"},
"companyIndustry": "SAAS_TECH",
"companySizeRange": "SIZE_11_50",
"aiMaturity": "EXPERIMENTING"
}'
Amounts use micros: $1 = 1,000,000 micros
| Dollar amount | amountMicros |
|---|---|
| $800 | 800,000,000 |
| $2,000 | 2,000,000,000 |
| $5,000 | 5,000,000,000 |
| $50,000 | 50,000,000,000 |
| $120,000 | 120,000,000,000 |
Convention: Store the annual deal value in amount, the monthly rate in useCase, and the deal type in contractType:
RETAINER — recurring monthly (amount = monthly × 12)PROJECT — one-time or fixed-duration (amount = total project value)Track referral attribution in leadSource + note the source in useCase (e.g. "$5,000/month retainer — referred via Vistage (Karl Sprague)").
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
# List opportunities
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/opportunities"
# Create opportunity → response: data.createOpportunity
# Example: $5,000/month retainer = $60,000 annual
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/opportunities" \
-d '{
"name": "Client Name - Monthly Retainer",
"stage": "WON",
"amount": {"amountMicros": 60000000000, "currencyCode": "USD"},
"contractType": "RETAINER",
"leadSource": "REFERRAL",
"budgetRange": "RANGE_5_15K",
"dealTimeline": "IMMEDIATE",
"useCase": "$5,000/month retainer — referred via Vistage (Karl Sprague)",
"companyId": "COMPANY_UUID",
"pointOfContactId": "PERSON_UUID"
}'
# Update opportunity → response: data.updateOpportunity
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/opportunities/{id}" \
-d '{"stage": "WON", "pointOfContactId": "PERSON_UUID"}'
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
# List tasks
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/tasks"
# Create task → response: data.createTask
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/tasks" \
-d '{
"title": "Follow up with client",
"status": "TODO",
"dueAt": "2026-03-15T00:00:00Z"
}'
TOKEN="$(grep TWENTY_API_KEY /opt/ph-infra/.env | cut -d= -f2)"
# List notes
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:3001/rest/notes"
# Create note → response: data.createNote
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"http://127.0.0.1:3001/rest/notes" \
-d '{
"title": "Meeting Notes",
"body": "Discussion about Q2 goals..."
}'
# Sort by creation date, limit results
curl -s -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3001/rest/companies?order_by=createdAt[AscNullsLast]&limit=10"
List responses include pagination info:
{
"totalCount": 623,
"pageInfo": {
"hasNextPage": true,
"endCursor": "eyJ..."
}
}
For field metadata lookups (enum options, field types), the REST metadata endpoint is slow and unreliable. Query the DB directly instead:
# Get enum options for a field
docker exec ph-postgres psql -U twenty -d twenty -c "
SELECT fm.name, fm.type, fm.options
FROM core.\"fieldMetadata\" fm
JOIN core.\"objectMetadata\" om ON om.id = fm.\"objectMetadataId\"
WHERE om.\"nameSingular\" = 'opportunity' AND fm.name = 'stage';
"
# List all fields for an entity
docker exec ph-postgres psql -U twenty -d twenty -c "
SELECT fm.name, fm.type
FROM core.\"fieldMetadata\" fm
JOIN core.\"objectMetadata\" om ON om.id = fm.\"objectMetadataId\"
WHERE om.\"nameSingular\" = 'opportunity'
ORDER BY fm.name;
"
Postgres container: ph-postgres, DB: twenty, User: twenty, Schemas: core (metadata), workspace_9guv38tcs38c4r7yknxi5fdqu (data).
amount, monthly breakdown in useCaseRETAINER for recurring, PROJECT for one-time, HYBRID for mix