计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
twenty-crm【效率与协作】Build custom CRM workflows with Twenty — open-source CRM alternative to Salesforce. Use when someone asks to "set up a CRM", "Twenty CRM", "open-source Salesforce alternative", "customer relationship management", "self-hosted CRM", or "CRM with API access". Covers contacts, companies, pipelines, custom objects, automations, and API integration.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Twenty is an open-source CRM shaped by the community — a modern alternative to Salesforce and HubSpot. It stores contacts, companies, and deals with customizable pipelines. The difference: it's fully open-source, self-hostable, has a powerful GraphQL/REST API, and supports custom objects (define your own data types). Built with React, Node.js, and PostgreSQL.
# Download the compose file and the example env from a fixed release, then verify both
base=https://raw.githubusercontent.com/twentyhq/twenty/twenty/v2.44.0/packages/twenty-docker
curl -fsSL $base/docker-compose.yml -o docker-compose.yml
curl -fsSL $base/.env.example -o .env
sha256sum -c - <<'EOF'
bacd817fcef85abbcb6a603a6c093375313460037fae67d73d45c16f6d85bc7d docker-compose.yml
dc5b4c779c5778a6bd40e39d338dae81815556094904b6dacc7d31b95e9d0865 .env
EOF
# Edit .env: uncomment and set these three
# ENCRYPTION_KEY -> generate with: openssl rand -base64 32
# PG_DATABASE_PASSWORD -> a strong password (no special characters)
# SERVER_URL -> http://localhost:3000 locally, your domain in prod
docker compose up -d
# First run creates the schema; open http://localhost:3000 and create the first account
The core GraphQL endpoint is /graphql (no /api prefix). emails, phones,
and domainName are composite fields — write them as nested objects, not
scalars.
// crm-client.ts — Interact with Twenty CRM via GraphQL
const TWENTY_URL = "http://localhost:3000/graphql";
const API_KEY = process.env.TWENTY_API_KEY;
async function graphql(query: string, variables?: Record<string, any>, url = TWENTY_URL) {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
return res.json();
}
// Create a company
const company = await graphql(`
mutation CreateCompany($input: CompanyCreateInput!) {
createCompany(data: $input) {
id
name
domainName { primaryLinkUrl }
}
}
`, {
input: {
name: "Riverside Labs",
domainName: { primaryLinkUrl: "https://riverside.io" },
},
});
// Create a contact (person) — emails/phones are composite
const person = await graphql(`
mutation CreatePerson($input: PersonCreateInput!) {
createPerson(data: $input) {
id
name { firstName lastName }
emails { primaryEmail }
}
}
`, {
input: {
name: { firstName: "Kai", lastName: "Chen" },
emails: { primaryEmail: "kai@riverside.io" },
phones: { primaryPhoneNumber: "4155550142", primaryPhoneCallingCode: "+1" },
companyId: company.data.createCompany.id,
jobTitle: "CTO",
},
});
// Query deals pipeline (stages are workspace-defined; the defaults are
// NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER)
const deals = await graphql(`
query GetDeals {
opportunities(filter: { stage: { in: [SCREENING, PROPOSAL] } }) {
edges {
node {
id
name
amount { amountMicros currencyCode }
stage
closeDate
company { name }
pointOfContact { name { firstName lastName } }
}
}
}
}
`);
The core REST endpoint is /rest (no /api prefix). Filters use
?filter=field[comparator]:value; comparators include eq, neq, in,
gt/gte/lt/lte, like, ilike, is, startsWith, endsWith. Combine
with and(...), or(...), not(...). Other params: order_by, limit,
depth (0 or 1), and the cursors starting_after / ending_before.
// rest-example.ts — REST API for simpler operations
const base = "http://localhost:3000/rest";
const headers = { Authorization: `Bearer ${API_KEY}` };
// List companies, newest first
const companies = await fetch(
`${base}/companies?order_by=createdAt[DescNullsLast]&limit=20`,
{ headers }
).then(r => r.json());
// Search people by email domain (composite field, % is URL-encoded as %25)
const contacts = await fetch(
`${base}/people?filter=emails.primaryEmail[ilike]:%25@riverside.io`,
{ headers }
).then(r => r.json());
Custom objects and fields are defined through the Metadata API at a
separate /metadata endpoint, or in Settings → Data model. The input is
wrapped in object: / field:, and SELECT option values must be UPPER_CASE.
// custom-objects.ts — Define your own data types via the Metadata API
const graphqlMetadata = (query: string) =>
graphql(query, undefined, "http://localhost:3000/metadata");
// Create a custom "Support Ticket" object
const customObject = await graphqlMetadata(`
mutation CreateCustomObject {
createOneObject(input: { object: {
nameSingular: "supportTicket"
namePlural: "supportTickets"
labelSingular: "Support Ticket"
labelPlural: "Support Tickets"
icon: "IconHeadset"
} }) {
id
}
}
`);
// Add a SELECT field to the object (objectMetadataId ties it to the object)
await graphqlMetadata(`
mutation AddField {
createOneField(input: { field: {
objectMetadataId: "${customObject.data.createOneObject.id}"
name: "priority"
label: "Priority"
type: SELECT
options: [
{ value: "LOW", label: "Low", color: "green", position: 0 },
{ value: "MEDIUM", label: "Medium", color: "yellow", position: 1 },
{ value: "HIGH", label: "High", color: "red", position: 2 }
]
} }) { id }
}
`);
User prompt: "Set up a CRM for our 10-person sales team. We need contacts, companies, deal pipeline, and activity tracking."
The agent will deploy Twenty via Docker, configure the sales pipeline stages, import existing contacts, and set up team access.
User prompt: "Sync our CRM contacts with our app's user database and send Slack notifications on deal stage changes."
The agent will use Twenty's GraphQL API to sync contacts, set up webhooks for deal events, and forward notifications to Slack.
/api prefix — core API is /rest and /graphql; metadata is /metadataemails, phones, domainName, and amount are objects, not scalars; write and read their sub-fields (primaryEmail, primaryLinkUrl, amountMicros)/metadata; don't force data into contacts/companies if it doesn't fit