--- title: 构建应用 description: 使用 Twenty SDK 定义对象、逻辑函数、前端组件等。 --- 应用目前处于 Alpha 阶段。 该功能可用,但仍在演进中。 `twenty-sdk` 包提供类型化的构建块,用于创建你的应用。 本页涵盖 SDK 中可用的所有实体类型和 API 客户端。 ## DefineEntity 函数 SDK 提供用于定义你的应用实体的函数。 你必须使用 `export default defineEntity({...})`,这样 SDK 才能检测到你的实体。 这些函数会在构建时校验你的配置,并提供 IDE 自动补全和类型安全。 **文件组织由你决定。** 实体检测基于 AST——无论文件位于何处,SDK 都能找到 `export default defineEntity(...)` 的调用。 按类型对文件分组(例如 `logic-functions/`、`roles/`)只是代码组织的一种约定,并非必需。 角色封装了对你的工作空间对象与操作的权限。 ```ts restricted-company-role.ts import { defineRole, PermissionFlag, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, } from 'twenty-sdk'; export default defineRole({ universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6', label: 'My new role', description: 'A role that can be used in your workspace', canReadAllObjectRecords: false, canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, canUpdateAllSettings: false, canBeAssignedToAgents: false, canBeAssignedToUsers: false, canBeAssignedToApiKeys: false, objectPermissions: [ { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, canReadObjectRecords: true, canUpdateObjectRecords: true, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, ], fieldPermissions: [ { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, fieldUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier, canReadFieldValue: false, canUpdateFieldValue: false, }, ], permissionFlags: [PermissionFlag.APPLICATIONS], }); ``` 每个应用必须且只能有一个 `defineApplication` 调用,用于描述: * **应用的身份**:标识符、显示名称和描述。 * **权限**:其函数和前端组件所使用的角色。 * **(可选)变量**:以环境变量形式提供给函数的键值对。 * **(可选)安装前/安装后函数**:在安装之前或之后运行的逻辑函数。 ```ts src/application-config.ts import { defineApplication } from 'twenty-sdk'; import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'; export default defineApplication({ universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', displayName: 'My Twenty App', description: 'My first Twenty app', icon: 'IconWorld', applicationVariables: { DEFAULT_RECIPIENT_NAME: { universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de', description: 'Default recipient name for postcards', value: 'Jane Doe', isSecret: false, }, }, defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, }); ``` 备注: * `universalIdentifier` 字段是你拥有的确定性 ID。 只需生成一次,并在多次同步过程中保持稳定不变。 * `applicationVariables` 会变成你的函数和前端组件可用的环境变量(例如,`DEFAULT_RECIPIENT_NAME` 可作为 `process.env.DEFAULT_RECIPIENT_NAME` 使用)。 * `defaultRoleUniversalIdentifier` 必须引用使用 `defineRole()` 定义的角色(见上文)。 * 在构建清单时会自动检测安装前/安装后函数——无需在 `defineApplication()` 中引用它们。 #### 应用市场元数据 如果你计划[发布你的应用](/l/zh/developers/extend/apps/publishing),这些可选字段将控制你的应用在应用市场中的展示: | 字段 | 描述 | | ------------------ | -------------------------------------------------------------- | | `作者` | 作者或公司名称 | | `类别` | 用于应用市场筛选的应用类别 | | `logoUrl` | 应用徽标的路径(例如 `public/logo.png`) | | `screenshots` | 截图路径数组(例如 `public/screenshot-1.png`) | | `aboutDescription` | 用于“关于”选项卡的更长的 Markdown 描述。 如果省略,市场将使用该软件包在 npm 上的 `README.md`。 | | `websiteUrl` | 你的网站链接 | | `termsUrl` | 服务条款链接 | | `emailSupport` | 支持电子邮件地址 | | `issueReportUrl` | 问题跟踪器链接 | #### 角色和权限 `application-config.ts` 中的 `defaultRoleUniversalIdentifier` 字段指定你的应用的逻辑函数和前端组件所使用的默认角色。 详见上文的 `defineRole`。 * 作为 `TWENTY_APP_ACCESS_TOKEN` 注入的运行时令牌来源于该角色。 * 类型化客户端将受限于该角色授予的权限。 * 遵循最小权限原则:创建一个仅包含你的函数所需权限的专用角色。 ##### 默认函数角色 当你使用脚手架创建新应用时,CLI 会创建一个默认角色文件: ```ts src/roles/default-role.ts import { defineRole, PermissionFlag } from 'twenty-sdk'; export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'b648f87b-1d26-4961-b974-0908fd991061'; export default defineRole({ universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, label: 'Default function role', description: 'Default role for function Twenty client', canReadAllObjectRecords: true, canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, canUpdateAllSettings: false, canBeAssignedToAgents: false, canBeAssignedToUsers: false, canBeAssignedToApiKeys: false, objectPermissions: [], fieldPermissions: [], permissionFlags: [], }); ``` 该角色的 `universalIdentifier` 会在 `application-config.ts` 中被引用为 `defaultRoleUniversalIdentifier`: * **\*.role.ts** 定义该角色可以执行的操作。 * **application-config.ts** 指向该角色,使你的函数继承其权限。 备注: * 从脚手架生成的角色开始,然后按照最小权限原则逐步收紧权限。 * 将 `objectPermissions` 和 `fieldPermissions` 替换为你的函数所需的对象/字段。 * `permissionFlags` 控制对平台级能力的访问。 尽量保持最小化。 * 查看一个可运行示例:[`hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts)。 自定义对象同时描述工作空间中记录的架构与行为。 使用 `defineObject()` 以内置校验定义对象: ```ts postCard.object.ts import { defineObject, FieldType } from 'twenty-sdk'; enum PostCardStatus { DRAFT = 'DRAFT', SENT = 'SENT', DELIVERED = 'DELIVERED', RETURNED = 'RETURNED', } export default defineObject({ universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', nameSingular: 'postCard', namePlural: 'postCards', labelSingular: 'Post Card', labelPlural: 'Post Cards', description: 'A post card object', icon: 'IconMail', fields: [ { universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', name: 'content', type: FieldType.TEXT, label: 'Content', description: "Postcard's content", icon: 'IconAbc', }, { universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac', name: 'recipientName', type: FieldType.FULL_NAME, label: 'Recipient name', icon: 'IconUser', }, { universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266', name: 'recipientAddress', type: FieldType.ADDRESS, label: 'Recipient address', icon: 'IconHome', }, { universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e', name: 'status', type: FieldType.SELECT, label: 'Status', icon: 'IconSend', defaultValue: `'${PostCardStatus.DRAFT}'`, options: [ { value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' }, { value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' }, { value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' }, { value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' }, ], }, { universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433', name: 'deliveredAt', type: FieldType.DATE_TIME, label: 'Delivered at', icon: 'IconCheck', isNullable: true, defaultValue: null, }, ], }); ``` 关键点: * 使用 `defineObject()` 以获得内置校验和更好的 IDE 支持。 * `universalIdentifier` 必须在各次部署间保持唯一且稳定。 * 每个字段都需要 `name`、`type`、`label` 以及其自身稳定的 `universalIdentifier`。 * `fields` 数组是可选的——你可以定义没有自定义字段的对象。 * 你可以使用 `yarn twenty add` 脚手架创建新对象,它会引导你完成命名、字段和关系。 **基础字段会自动创建。** 当你定义自定义对象时,Twenty 会自动添加标准字段 例如 `id`、`name`、`createdAt`、`updatedAt`、`createdBy`、`updatedBy` 和 `deletedAt`。 你无需在 `fields` 数组中定义这些字段——只需添加你的自定义字段。 你可以通过在你的 `fields` 数组中定义一个同名字段来覆盖默认字段, 但不建议这样做。 使用 `defineField()` 向你不拥有的对象添加字段——例如标准的 Twenty 对象(Person、Company 等)。 或来自其他应用的对象。 与在 `defineObject()` 中的内联字段不同,独立字段需要一个 `objectUniversalIdentifier` 来指定它们要扩展的对象: ```ts src/fields/company-loyalty-tier.field.ts import { defineField, FieldType } from 'twenty-sdk'; export default defineField({ universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890', objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object name: 'loyaltyTier', type: FieldType.SELECT, label: 'Loyalty Tier', icon: 'IconStar', options: [ { value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' }, { value: 'SILVER', label: 'Silver', position: 1, color: 'gray' }, { value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' }, ], }); ``` 关键点: * `objectUniversalIdentifier` 用于标识目标对象。 对于标准对象,请使用从 `twenty-sdk` 导出的 `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`。 * 在 `defineObject()` 中以内联方式定义字段时,你不需要 `objectUniversalIdentifier`——它会从父对象继承。 * `defineField()` 是为非通过 `defineObject()` 创建的对象添加字段的唯一方式。 关系用于将对象彼此连接。 在 Twenty 中,关系始终是双向的——你需要定义两侧,每一侧都引用另一侧。 关系有两种类型: | 关系类型 | 描述 | 是否有外键? | | ------------- | ------------------- | ------------------- | | `MANY_TO_ONE` | 该对象的多条记录指向目标对象的一条记录 | 是(`joinColumnName`) | | `ONE_TO_MANY` | 该对象的一条记录拥有目标对象的多条记录 | 否(反向侧) | #### 关系如何工作 每个关系都需要两个相互引用的字段: 1. **MANY_TO_ONE** 侧——位于持有外键的对象上 2. **ONE_TO_MANY** 侧——位于拥有集合的对象上 两个字段都使用 `FieldType.RELATION`,并通过 `relationTargetFieldMetadataUniversalIdentifier` 相互交叉引用。 #### 示例:Post Card 拥有多个收件人 假设一个 `PostCard` 可以发送到多个 `PostCardRecipient` 记录。 每个收件人只隶属于一张 Post Card。 **步骤 1:在 PostCard 上定义 ONE_TO_MANY 侧**(“一”侧): ```ts src/fields/post-card-recipients-on-post-card.field.ts import { defineField, FieldType, RelationType } from 'twenty-sdk'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object'; // Export so the other side can reference it export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111'; // Import from the other side import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field'; export default defineField({ universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID, objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, type: FieldType.RELATION, name: 'postCardRecipients', label: 'Post Card Recipients', icon: 'IconUsers', relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER, relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID, universalSettings: { relationType: RelationType.ONE_TO_MANY, }, }); ``` **步骤 2:在 PostCardRecipient 上定义 MANY_TO_ONE 侧**(“多”侧——持有外键): ```ts src/fields/post-card-on-post-card-recipient.field.ts import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object'; // Export so the other side can reference it export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222'; // Import from the other side import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field'; export default defineField({ universalIdentifier: POST_CARD_FIELD_ID, objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER, type: FieldType.RELATION, name: 'postCard', label: 'Post Card', icon: 'IconMail', relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID, universalSettings: { relationType: RelationType.MANY_TO_ONE, onDelete: OnDeleteAction.CASCADE, joinColumnName: 'postCardId', }, }); ``` \*\*循环导入:\*\*两个关系字段相互引用彼此的 `universalIdentifier`。 为避免循环导入问题,请在各自文件中将字段 ID 作为具名常量导出,并在另一个文件中导入它们。 构建系统会在编译时解析这些引用。 #### 与标准对象建立关系 要与内置的 Twenty 对象(Person、Company 等)建立关系,请使用 `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`: ```ts src/fields/person-on-self-hosting-user.field.ts import { defineField, FieldType, RelationType, OnDeleteAction, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, } from 'twenty-sdk'; import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object'; export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333'; export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444'; export default defineField({ universalIdentifier: PERSON_FIELD_ID, objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER, type: FieldType.RELATION, name: 'person', label: 'Person', description: 'Person matching with the self hosting user', isNullable: true, relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID, universalSettings: { relationType: RelationType.MANY_TO_ONE, onDelete: OnDeleteAction.SET_NULL, joinColumnName: 'personId', }, }); ``` #### 关系字段属性 | 属性 | 必填 | 描述 | | ------------------------------------------------- | ---------------- | -------------------------------------------------------------- | | `类型` | 是 | 必须为 `FieldType.RELATION` | | `relationTargetObjectMetadataUniversalIdentifier` | 是 | 目标对象的 `universalIdentifier` | | `relationTargetFieldMetadataUniversalIdentifier` | 是 | 目标对象上匹配字段的 `universalIdentifier` | | `universalSettings.relationType` | 是 | `RelationType.MANY_TO_ONE` 或 `RelationType.ONE_TO_MANY` | | `universalSettings.onDelete` | 仅适用于 MANY_TO_ONE | 当被引用的记录被删除时的处理方式:`CASCADE`、`SET_NULL`、`RESTRICT` 或 `NO_ACTION` | | `universalSettings.joinColumnName` | 仅适用于 MANY_TO_ONE | 外键的数据库列名(例如,`postCardId`) | #### 在 defineObject 中内联关系字段 你也可以直接在 `defineObject()` 内定义关系字段。 在这种情况下,省略 `objectUniversalIdentifier`——它会从父对象继承: ```ts export default defineObject({ universalIdentifier: '...', nameSingular: 'postCardRecipient', // ... fields: [ { universalIdentifier: POST_CARD_FIELD_ID, type: FieldType.RELATION, name: 'postCard', label: 'Post Card', relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID, universalSettings: { relationType: RelationType.MANY_TO_ONE, onDelete: OnDeleteAction.CASCADE, joinColumnName: 'postCardId', }, }, // ... other fields ], }); ``` 每个函数文件都使用 `defineLogicFunction()` 导出包含处理程序和可选触发器的配置。 ```ts src/logic-functions/createPostCard.logic-function.ts import { defineLogicFunction } from 'twenty-sdk'; import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk'; import { CoreApiClient, type Person } from 'twenty-client-sdk/core'; const handler = async (params: RoutePayload) => { const client = new CoreApiClient(); const name = 'name' in params.queryStringParameters ? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world' : 'Hello world'; const result = await client.mutation({ createPostCard: { __args: { data: { name } }, id: true, name: true, }, }); return result; }; export default defineLogicFunction({ universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', name: 'create-new-post-card', timeoutSeconds: 2, handler, httpRouteTriggerSettings: { path: '/post-card/create', httpMethod: 'GET', isAuthRequired: false, }, /*databaseEventTriggerSettings: { eventName: 'people.created', },*/ /*cronTriggerSettings: { pattern: '0 0 1 1 *', },*/ }); ``` 可用的触发器类型: * **httpRoute**:在 **`/s/` 端点**下通过 HTTP 路径和方法公开你的函数: > 例如 `path: '/post-card/create'` 可在 `https://your-twenty-server.com/s/post-card/create` 调用 * **cron**:使用 CRON 表达式按计划运行你的函数。 * **databaseEvent**:在工作空间对象生命周期事件上运行。 当事件操作为 `updated` 时,可以在 `updatedFields` 数组中指定要监听的特定字段。 如果未定义或为空,任何更新都会触发该函数。 > 例如 `person.updated`、`*.created`、`company.*` 你也可以使用 CLI 手动执行函数: ```bash filename="Terminal" yarn twenty exec -n create-new-post-card -p '{"key": "value"}' ``` ```bash filename="Terminal" yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf ``` 你可以通过以下方式查看日志: ```bash filename="Terminal" yarn twenty logs ``` #### 路由触发器负载 当路由触发器调用你的逻辑函数时,它会接收一个遵循 [AWS HTTP API v2 格式](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)的 `RoutePayload` 对象。 从 `twenty-sdk` 导入 `RoutePayload` 类型: ```ts import { defineLogicFunction, type RoutePayload } from 'twenty-sdk'; const handler = async (event: RoutePayload) => { const { headers, queryStringParameters, pathParameters, body } = event; const { method, path } = event.requestContext.http; return { message: 'Success' }; }; ``` `RoutePayload` 类型具有以下结构: | 属性 | 类型 | 描述 | 示例 | | ---------------------------- | ------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- | | `headers` | `Record` | HTTP 请求头(仅限 `forwardedRequestHeaders` 中列出的那些) | 见下文 | | `queryStringParameters` | `Record` | 查询字符串参数(多个值以逗号连接) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` | | `pathParameters` | `Record` | 从路由模式中提取的路径参数 | `/users/:id`,`/users/123` -> `{ id: '123' }` | | `body` | `object \| null` | 已解析的请求体(JSON) | `{ id: 1 }` -> `{ id: 1 }` | | `isBase64Encoded` | `boolean` | 请求体是否为 base64 编码 | | | `requestContext.http.method` | `string` | HTTP 方法(GET、POST、PUT、PATCH、DELETE) | | | `requestContext.http.path` | `string` | 原始请求路径 | | #### forwardedRequestHeaders 出于安全原因,默认**不会**将传入请求的 HTTP 请求头传递给你的逻辑函数。 如需访问特定请求头,请在 `forwardedRequestHeaders` 数组中显式列出: ```ts export default defineLogicFunction({ universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', name: 'webhook-handler', handler, httpRouteTriggerSettings: { path: '/webhook', httpMethod: 'POST', isAuthRequired: false, forwardedRequestHeaders: ['x-webhook-signature', 'content-type'], }, }); ``` 在你的处理程序中,可以这样访问被转发的请求头: ```ts const handler = async (event: RoutePayload) => { const signature = event.headers['x-webhook-signature']; const contentType = event.headers['content-type']; // Validate webhook signature... return { received: true }; }; ``` 请求头名称会被规范化为小写。 请使用小写键访问它们(例如,`event.headers['content-type']`)。 #### 将函数作为工具公开 逻辑函数可以作为供 AI 智能体和工作流使用的**工具**对外提供。 当函数被标记为工具时,Twenty 的 AI 功能即可发现它,并可在工作流自动化中使用。 要将逻辑函数标记为工具,请设置 `isTool: true`: ```ts src/logic-functions/enrich-company.logic-function.ts import { defineLogicFunction } from 'twenty-sdk'; import { CoreApiClient } from 'twenty-client-sdk/core'; const handler = async (params: { companyName: string; domain?: string }) => { const client = new CoreApiClient(); const result = await client.mutation({ createTask: { __args: { data: { title: `Enrich data for ${params.companyName}`, body: `Domain: ${params.domain ?? 'unknown'}`, }, }, id: true, }, }); return { taskId: result.createTask.id }; }; export default defineLogicFunction({ universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', name: 'enrich-company', description: 'Enrich a company record with external data', timeoutSeconds: 10, handler, isTool: true, }); ``` 关键点: * 你可以将 `isTool` 与触发器结合使用——一个函数既可以作为工具(由 AI 代理调用),也可以同时由事件触发。 * **`toolInputSchema`**(可选):描述函数可接受参数的 JSON Schema 对象。 该模式会通过对源代码的静态分析自动推导,但你也可以显式设置: ```ts export default defineLogicFunction({ ..., toolInputSchema: { type: 'object', properties: { companyName: { type: 'string', description: 'The name of the company to enrich', }, domain: { type: 'string', description: 'The company website domain (optional)', }, }, required: ['companyName'], }, }); ``` **写一个好的 `description`。** AI 代理会依赖该函数的 `description` 字段来决定何时使用该工具。 明确说明该工具的作用以及应在何时调用。 安装前函数是在你的应用安装到工作区之前自动运行的逻辑函数。 这对于执行验证任务、先决条件检查,或在主安装开始前准备工作区状态很有用。 ```ts src/logic-functions/pre-install.ts import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; const handler = async (payload: InstallLogicFunctionPayload): Promise => { console.log('Pre install logic function executed successfully!', payload.previousVersion); }; export default definePreInstallLogicFunction({ universalIdentifier: 'e0604b9e-e946-456b-886d-3f27d9a6b324', name: 'pre-install', description: 'Runs before installation to prepare the application.', timeoutSeconds: 300, handler, }); ``` 你也可以随时使用 CLI 手动执行安装前函数: ```bash filename="Terminal" yarn twenty exec --preInstall ``` 关键点: * 安装前函数使用 `definePreInstallLogicFunction()` —— 这是一个省略触发器设置(`cronTriggerSettings`、`databaseEventTriggerSettings`、`httpRouteTriggerSettings`、`isTool`)的专用变体。 * 处理器会接收一个 `InstallLogicFunctionPayload`,其包含 `{ previousVersion: string }` —— 即之前安装的应用版本(全新安装则为空字符串)。 * 每个应用仅允许一个安装前函数。 如果检测到多个,清单构建将报错。 * 在构建期间,函数的 `universalIdentifier` 会自动设置为应用清单上的 `preInstallLogicFunctionUniversalIdentifier` —— 你无需在 `defineApplication()` 中引用它。 * 默认超时时间设置为 300 秒(5 分钟),以便支持更长的准备任务。 安装后函数是在你的应用安装到工作区后自动运行的逻辑函数。 这对于一次性设置任务很有用,例如填充默认数据、创建初始记录或配置工作区设置。 ```ts src/logic-functions/post-install.ts import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; const handler = async (payload: InstallLogicFunctionPayload): Promise => { console.log('Post install logic function executed successfully!', payload.previousVersion); }; export default definePostInstallLogicFunction({ universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210', name: 'post-install', description: 'Runs after installation to set up the application.', timeoutSeconds: 300, handler, }); ``` 你也可以随时使用 CLI 手动执行安装后函数: ```bash filename="Terminal" yarn twenty exec --postInstall ``` 关键点: * 安装后函数使用 `definePostInstallLogicFunction()` —— 这是一个省略触发器设置(`cronTriggerSettings`、`databaseEventTriggerSettings`、`httpRouteTriggerSettings`、`isTool`)的专用变体。 * 处理器会接收一个 `InstallLogicFunctionPayload`,其包含 `{ previousVersion: string }` —— 即之前安装的应用版本(全新安装则为空字符串)。 * 每个应用仅允许一个安装后函数。 如果检测到多个,清单构建将报错。 * 在构建期间,函数的 `universalIdentifier` 会自动设置为应用清单上的 `postInstallLogicFunctionUniversalIdentifier` —— 你无需在 `defineApplication()` 中引用它。 * 默认超时时间设置为 300 秒(5 分钟),以便支持更长的设置任务,如数据填充。 前端组件是直接在 Twenty 的 UI 内渲染的 React 组件。 它们在使用 Remote DOM 的**隔离 Web Worker**中运行——你的代码在沙盒中执行,但会原生渲染到页面中,而非在 iframe 里。 #### 基础示例 最快体验前端组件运行方式的方法是将其注册为一个**命令**。 添加一个 `command` 字段并设置 `isPinned: true`,即可让它以快速操作按钮的形式出现在页面右上角——无需页面布局: ```tsx src/front-components/hello-world.tsx import { defineFrontComponent } from 'twenty-sdk'; const HelloWorld = () => { return (

Hello from my app!

This component renders inside Twenty.

); }; export default defineFrontComponent({ universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948', name: 'hello-world', description: 'A simple front component', component: HelloWorld, command: { universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345', shortLabel: 'Hello', label: 'Hello World', icon: 'IconBolt', isPinned: true, availabilityType: 'GLOBAL', }, }); ``` 使用 `yarn twenty dev` 同步后,快速操作会出现在页面右上角:
右上角的快速操作按钮
点击它以内联方式渲染该组件。 {/* TODO: add screenshot of the rendered front component */} #### 配置字段 | 字段 | 必填 | 描述 | | --------------------- | -- | ----------------------------------------------------------------------------------- | | `universalIdentifier` | 是 | 该组件的稳定唯一 ID | | `component` | 是 | 一个 React 组件函数 | | `name` | 否 | 显示名称 | | `描述` | 否 | 组件的功能描述 | | `isHeadless` | 否 | Set to `true` if the component has no visible UI (see below) | | `命令` | 否 | Register the component as a command (see [command options](#command-options) below) | #### Placing a front component on a page Beyond commands, you can embed a front component directly into a record page by adding it as a widget in a **page layout**. See the [definePageLayout](#definepagelayout) section for details. #### Headless components (`isHeadless: true`) Headless components render no visible UI but still run React logic. This is useful for **effect components** — components that perform side effects when mounted, such as syncing data, starting a timer, listening to events, or triggering a notification. ```tsx src/front-components/sync-tracker.tsx import { defineFrontComponent, useRecordId, enqueueSnackbar } from 'twenty-sdk'; import { useEffect } from 'react'; const SyncTracker = () => { const recordId = useRecordId(); useEffect(() => { enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' }); }, [recordId]); return null; }; export default defineFrontComponent({ universalIdentifier: '...', name: 'sync-tracker', description: 'Tracks record views silently', isHeadless: true, component: SyncTracker, }); ``` Because the component returns `null`, Twenty skips rendering a container for it — no empty space appears in the layout. The component still has access to all hooks and the host communication API. #### Accessing runtime context Inside your component, use SDK hooks to access the current user, record, and component instance: ```tsx src/front-components/record-info.tsx import { defineFrontComponent, useUserId, useRecordId, useFrontComponentId, } from 'twenty-sdk'; const RecordInfo = () => { const userId = useUserId(); const recordId = useRecordId(); const componentId = useFrontComponentId(); return (

User: {userId}

Record: {recordId ?? 'No record context'}

Component: {componentId}

); }; export default defineFrontComponent({ universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012', name: 'record-info', component: RecordInfo, }); ``` Available hooks: | 钩子 | Returns | 描述 | | --------------------------------------------- | ------------------ | ---------------------------------------------------------- | | `useUserId()` | `string` or `null` | The current user's ID | | `useRecordId()` | `string` or `null` | The current record's ID (when placed on a record page) | | `useFrontComponentId()` | `string` | This component instance's ID | | `useFrontComponentExecutionContext(selector)` | 因情况而异 | Access the full execution context with a selector function | #### Host communication API Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`: | 函数 | 描述 | | ----------------------------------------------- | ----------------------------- | | `navigate(to, params?, queryParams?, options?)` | Navigate to a page in the app | | `openSidePanelPage(params)` | Open a side panel | | `closeSidePanel()` | 关闭侧边栏 | | `openCommandConfirmationModal(params)` | Show a confirmation dialog | | `enqueueSnackbar(params)` | Show a toast notification | | `unmountFrontComponent()` | Unmount the component | | `updateProgress(progress)` | Update a progress indicator | #### Command options Adding a `command` field to `defineFrontComponent` registers the component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page. | 字段 | 必填 | 描述 | | --------------------------------------- | -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `universalIdentifier` | 是 | Stable unique ID for the command | | `标签` | 是 | Full label shown in the command menu (Cmd+K) | | `shortLabel` | 否 | Shorter label displayed on the pinned quick-action button | | `图标` | 否 | Icon name displayed next to the label (e.g. `'IconBolt'`, `'IconSend'`) | | `isPinned` | 否 | When `true`, shows the command as a quick-action button in the top-right corner of the page | | `availabilityType` | 否 | Controls where the command appears: `'GLOBAL'` (always available), `'RECORD_SELECTION'` (only when records are selected), or `'FALLBACK'` (shown when no other commands match) | | `availabilityObjectUniversalIdentifier` | 否 | Restrict the command to pages of a specific object type (e.g. only on Company records) | | `conditionalAvailabilityExpression` | 否 | A boolean expression to dynamically control whether the command is visible (see below) | #### Conditional availability expressions The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions: ```tsx import { defineFrontComponent, pageType, numberOfSelectedRecords, objectPermissions, everyEquals, isDefined, } from 'twenty-sdk'; export default defineFrontComponent({ universalIdentifier: '...', name: 'bulk-action', component: BulkAction, command: { universalIdentifier: '...', label: 'Bulk Update', availabilityType: 'RECORD_SELECTION', conditionalAvailabilityExpression: everyEquals( objectPermissions, 'canUpdateObjectRecords', true, ), }, }); ``` **Context variables** — these represent the current state of the page: | 变量 | 类型 | 描述 | | ------------------------------ | --------- | ---------------------------------------------------------------- | | `pageType` | `string` | Current page type (e.g. `'RecordIndexPage'`, `'RecordShowPage'`) | | `isInSidePanel` | `boolean` | Whether the component is rendered in a side panel | | `numberOfSelectedRecords` | `数字` | Number of currently selected records | | `isSelectAll` | `boolean` | Whether "select all" is active | | `selectedRecords` | `array` | The selected record objects | | `favoriteRecordIds` | `array` | IDs of favorited records | | `objectPermissions` | `对象` | Permissions for the current object type | | `targetObjectReadPermissions` | `对象` | Read permissions for the target object | | `targetObjectWritePermissions` | `对象` | Write permissions for the target object | | `featureFlags` | `对象` | Active feature flags | | `objectMetadataItem` | `对象` | Metadata of the current object type | | `hasAnySoftDeleteFilterOnView` | `boolean` | Whether the current view has a soft-delete filter | **Operators** — combine variables into boolean expressions: | Operator | 描述 | | ----------------------------------- | ----------------------------------------------------------------- | | `isDefined(value)` | `true` if the value is not null/undefined | | `isNonEmptyString(value)` | `true` if the value is a non-empty string | | `includes(array, value)` | `true` if the array contains the value | | `includesEvery(array, prop, value)` | `true` if every item's property includes the value | | `every(array, prop)` | `true` if the property is truthy on every item | | `everyDefined(array, prop)` | `true` if the property is defined on every item | | `everyEquals(array, prop, value)` | `true` if the property equals the value on every item | | `some(array, prop)` | `true` if the property is truthy on at least one item | | `someDefined(array, prop)` | `true` if the property is defined on at least one item | | `someEquals(array, prop, value)` | `true` if the property equals the value on at least one item | | `someNonEmptyString(array, prop)` | `true` if the property is a non-empty string on at least one item | | `none(array, prop)` | `true` if the property is falsy on every item | | `noneDefined(array, prop)` | `true` if the property is undefined on every item | | `noneEquals(array, prop, value)` | `true` if the property does not equal the value on any item | #### Public assets Front components can access files from the app's `public/` directory using `getPublicAssetUrl`: ```tsx import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk'; const Logo = () => Logo; export default defineFrontComponent({ universalIdentifier: '...', name: 'logo', component: Logo, }); ``` See the [public assets section](#accessing-public-assets-with-getpublicasseturl) for details. #### 样式 Front components support multiple styling approaches. You can use: * **Inline styles** — `style={{ color: 'red' }}` * **Twenty UI components** — import from `twenty-sdk/ui` (Button, Tag, Status, Chip, Avatar, and more) * **Emotion** — CSS-in-JS with `@emotion/react` * **Styled-components** — `styled.div` patterns * **Tailwind CSS** — utility classes * **Any CSS-in-JS library** compatible with React ```tsx import { defineFrontComponent } from 'twenty-sdk'; import { Button, Tag, Status } from 'twenty-sdk/ui'; const StyledWidget = () => { return (
); }; export default defineFrontComponent({ universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456', name: 'styled-widget', component: StyledWidget, }); ```
技能定义了可复用的指令和能力,AI 智能体可在你的工作区中使用。 使用 `defineSkill()` 定义带内置校验的技能: ```ts src/skills/example-skill.ts import { defineSkill } from 'twenty-sdk'; export default defineSkill({ universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', name: 'sales-outreach', label: 'Sales Outreach', description: 'Guides the AI agent through a structured sales outreach process', icon: 'IconBrain', content: `You are a sales outreach assistant. When reaching out to a prospect: 1. Research the company and recent news 2. Identify the prospect's role and likely pain points 3. Draft a personalized message referencing specific details 4. Keep the tone professional but conversational`, }); ``` 关键点: * `name` 是该技能的唯一标识字符串(推荐使用 kebab-case)。 * `label` 是在 UI 中显示的人类可读名称。 * `content` 包含技能指令——这是 AI 智能体使用的文本。 * `icon`(可选)设置在 UI 中显示的图标。 * `description`(可选)提供有关技能用途的更多上下文。 Agents are AI assistants that live inside your workspace. Use `defineAgent()` to create agents with a custom system prompt: ```ts src/agents/example-agent.ts import { defineAgent } from 'twenty-sdk'; export default defineAgent({ universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123', name: 'sales-assistant', label: 'Sales Assistant', description: 'Helps the sales team draft outreach emails and research prospects', icon: 'IconRobot', prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.', }); ``` 关键点: * `name` is the unique identifier string for the agent (kebab-case recommended). * `label` is the display name shown in the UI. * `prompt` is the system prompt that defines the agent's behavior. * `description` (optional) provides context about what the agent does. * `icon`(可选)设置在 UI 中显示的图标。 * `modelId` (optional) overrides the default AI model used by the agent. Views are saved configurations for how records of an object are displayed — including which fields are visible, their order, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app: ```ts src/views/example-view.ts import { defineView, ViewKey } from 'twenty-sdk'; import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object'; import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object'; export default defineView({ universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', name: 'All example items', objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, icon: 'IconList', key: ViewKey.INDEX, position: 0, fields: [ { universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0', fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, position: 0, isVisible: true, size: 200, }, ], }); ``` 关键点: * `objectUniversalIdentifier` specifies which object this view applies to. * `key` determines the view type (e.g., `ViewKey.INDEX` for the main list view). * `fields` controls which columns appear and their order. Each field references a `fieldMetadataUniversalIdentifier`. * You can also define `filters`, `filterGroups`, `groups`, and `fieldGroups` for more advanced configurations. * `position` controls the ordering when multiple views exist for the same object. Navigation menu items add custom entries to the workspace sidebar. Use `defineNavigationMenuItem()` to link to views, external URLs, or objects: ```ts src/navigation-menu-items/example-navigation-menu-item.ts import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk'; import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view'; export default defineNavigationMenuItem({ universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c', name: 'example-navigation-menu-item', icon: 'IconList', color: 'blue', position: 0, type: NavigationMenuItemType.VIEW, viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER, }); ``` 关键点: * `type` determines what the menu item links to: `NavigationMenuItemType.VIEW` for a saved view, or `NavigationMenuItemType.LINK` for an external URL. * For view links, set `viewUniversalIdentifier`. For external links, set `link`. * `position` controls the ordering in the sidebar. * `icon` and `color` (optional) customize the appearance. Page layouts let you customize how a record detail page looks — which tabs appear, what widgets are inside each tab, and how they are arranged. Use `definePageLayout()` to ship custom layouts with your app: ```ts src/page-layouts/example-record-page-layout.ts import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk'; import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object'; import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world'; export default definePageLayout({ universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134', name: 'Example Record Page', type: 'RECORD_PAGE', objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, tabs: [ { universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5', title: 'Hello World', position: 50, icon: 'IconWorld', layoutMode: PageLayoutTabLayoutMode.CANVAS, widgets: [ { universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d', title: 'Hello World', type: 'FRONT_COMPONENT', configuration: { configurationType: 'FRONT_COMPONENT', frontComponentUniversalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, }, }, ], }, ], }); ``` 关键点: * `type` is typically `'RECORD_PAGE'` to customize the detail view of a specific object. * `objectUniversalIdentifier` specifies which object this layout applies to. * Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`CANVAS` for free-form layout). * Each `widget` inside a tab can render a front component, a relation list, or other built-in widget types. * `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
## Public assets (`public/` folder) The `public/` folder at the root of your app holds static files — images, icons, fonts, or any other assets your app needs at runtime. These files are automatically included in builds, synced during dev mode, and uploaded to the server. Files placed in `public/` are: * **Publicly accessible** — once synced to the server, assets are served at a public URL. No authentication is needed to access them. * **Available in front components** — use asset URLs to display images, icons, or any media inside your React components. * **Available in logic functions** — reference asset URLs in emails, API responses, or any server-side logic. * **Used for marketplace metadata** — the `logoUrl` and `screenshots` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published. * **Auto-synced in dev mode** — when you add, update, or delete a file in `public/`, it is synced to the server automatically. No restart needed. * **Included in builds** — `yarn twenty build` bundles all public assets into the distribution output. ### Accessing public assets with `getPublicAssetUrl` Use the `getPublicAssetUrl` helper from `twenty-sdk` to get the full URL of a file in your `public/` directory. It works in both **logic functions** and **front components**. **In a logic function:** ```ts src/logic-functions/send-invoice.ts import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk'; const handler = async (): Promise => { const logoUrl = getPublicAssetUrl('logo.png'); const invoiceUrl = getPublicAssetUrl('templates/invoice.png'); // Fetch the file content (no auth required — public endpoint) const response = await fetch(invoiceUrl); const buffer = await response.arrayBuffer(); return { logoUrl, size: buffer.byteLength }; }; export default defineLogicFunction({ universalIdentifier: 'a1b2c3d4-...', name: 'send-invoice', description: 'Sends an invoice with the app logo', timeoutSeconds: 10, handler, }); ``` **In a front component:** ```tsx src/front-components/company-card.tsx import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk'; export default defineFrontComponent(() => { const logoUrl = getPublicAssetUrl('logo.png'); return App logo; }); ``` The `path` argument is relative to your app's `public/` folder. Both `getPublicAssetUrl('logo.png')` and `getPublicAssetUrl('public/logo.png')` resolve to the same URL — the `public/` prefix is stripped automatically if present. ## Using npm packages You can install and use any npm package in your app. Both logic functions and front components are bundled with [esbuild](https://esbuild.github.io/), which inlines all dependencies into the output — no `node_modules` are needed at runtime. ### Installing a package ```bash filename="Terminal" yarn add axios ``` Then import it in your code: ```ts src/logic-functions/fetch-data.ts import { defineLogicFunction } from 'twenty-sdk'; import axios from 'axios'; const handler = async (): Promise => { const { data } = await axios.get('https://api.example.com/data'); return { data }; }; export default defineLogicFunction({ universalIdentifier: '...', name: 'fetch-data', description: 'Fetches data from an external API', timeoutSeconds: 10, handler, }); ``` The same works for front components: ```tsx src/front-components/chart.tsx import { defineFrontComponent } from 'twenty-sdk'; import { format } from 'date-fns'; const DateWidget = () => { return

Today is {format(new Date(), 'MMMM do, yyyy')}

; }; export default defineFrontComponent({ universalIdentifier: '...', name: 'date-widget', component: DateWidget, }); ``` ### How bundling works The build step (`yarn twenty dev` or `yarn twenty build`) uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle. **Logic functions** run in a Node.js environment. Node built-in modules (`fs`, `path`, `crypto`, `http`, etc.) are available and do not need to be installed. **Front components** run in a Web Worker. Node built-in modules are **not** available — only browser APIs and npm packages that work in a browser environment. Both environments have `twenty-client-sdk/core` and `twenty-client-sdk/metadata` available as pre-provided modules — these are not bundled but resolved at runtime by the server. ## Scaffolding entities with `yarn twenty add` Instead of creating entity files by hand, you can use the interactive scaffolder: ```bash filename="Terminal" yarn twenty add ``` This prompts you to pick an entity type and walks you through the required fields. It generates a ready-to-use file with a stable `universalIdentifier` and the correct `defineEntity()` call. You can also pass the entity type directly to skip the first prompt: ```bash filename="Terminal" yarn twenty add object yarn twenty add logicFunction yarn twenty add frontComponent ``` ### Available entity types | 实体类型 | 命令 | Generated file | | -------------------- | ------------------------------------ | ------------------------------------- | | 对象 | `yarn twenty add object` | `src/objects/.ts` | | 字段 | `yarn twenty add field` | `src/fields/.ts` | | Logic function | `yarn twenty add logicFunction` | `src/logic-functions/.ts` | | Front component | `yarn twenty add frontComponent` | `src/front-components/.tsx` | | 角色 | `yarn twenty add role` | `src/roles/.ts` | | 技能 | `yarn twenty add skill` | `src/skills/.ts` | | 代理 | `yarn twenty add agent` | `src/agents/.ts` | | 视图 | `yarn twenty add view` | `src/views/.ts` | | Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/.ts` | | Page layout | `yarn twenty add pageLayout` | `src/page-layouts/.ts` | ### What the scaffolder generates Each entity type has its own template. For example, `yarn twenty add object` asks for: 1. **Name (singular)** — e.g., `invoice` 2. **Name (plural)** — e.g., `invoices` 3. **Label (singular)** — auto-populated from the name (e.g., `Invoice`) 4. **Label (plural)** — auto-populated (e.g., `Invoices`) 5. **Create a view and navigation item?** — if you answer yes, the scaffolder also generates a matching view and sidebar link for the new object. Other entity types have simpler prompts — most only ask for a name. The `field` entity type is more detailed: it asks for the field name, label, type (from a list of all available field types like `TEXT`, `NUMBER`, `SELECT`, `RELATION`, etc.), and the target object's `universalIdentifier`. ### Custom output path Use the `--path` flag to place the generated file in a custom location: ```bash filename="Terminal" yarn twenty add logicFunction --path src/custom-folder ``` ## Typed API clients (twenty-client-sdk) The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components. | 客户端 | 导入 | 端点 | 是否生成? | | ------------------- | ---------------------------- | ------------------------ | --------- | | `CoreApiClient` | `twenty-client-sdk/core` | `/graphql`——工作区数据(记录、对象) | 是,在开发/构建时 | | `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata`——工作区配置、文件上传 | 否,已预构建提供 | `CoreApiClient` 是用于查询和变更工作区数据的主要客户端。 It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it is fully typed to match your objects and fields. ```ts import { CoreApiClient } from 'twenty-client-sdk/core'; const client = new CoreApiClient(); // Query records const { companies } = await client.query({ companies: { edges: { node: { id: true, name: true, domainName: { primaryLinkLabel: true, primaryLinkUrl: true, }, }, }, }, }); // Create a record const { createCompany } = await client.mutation({ createCompany: { __args: { data: { name: 'Acme Corp', }, }, id: true, name: true, }, }); ``` 该客户端使用选择集语法:传入 `true` 以包含某字段,使用 `__args` 传递参数,并通过嵌套对象表示关系。 你将基于工作区架构获得完整的自动补全和类型检查。 **CoreApiClient is generated at dev/build time.** If you use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema and generates a typed client using `@genql/cli`. #### 使用 CoreSchema 进行类型标注 `CoreSchema` provides TypeScript types matching your workspace objects — useful for typing component state or function parameters: ```ts import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; import { useState } from 'react'; const [company, setCompany] = useState< Pick | undefined >(undefined); const client = new CoreApiClient(); const result = await client.query({ company: { __args: { filter: { position: { eq: 1 } } }, id: true, name: true, }, }); setCompany(result.company); ``` `MetadataApiClient` 随 SDK 一并提供,已预构建(无需生成)。 It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads. ```ts import { MetadataApiClient } from 'twenty-client-sdk/metadata'; const metadataClient = new MetadataApiClient(); // List first 10 objects in the workspace const { objects } = await metadataClient.query({ objects: { edges: { node: { id: true, nameSingular: true, namePlural: true, labelSingular: true, isCustom: true, }, }, __args: { filter: {}, paging: { first: 10 }, }, }, }); ``` #### 上传文件 `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields: ```ts import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import * as fs from 'fs'; const metadataClient = new MetadataApiClient(); const fileBuffer = fs.readFileSync('./invoice.pdf'); const uploadedFile = await metadataClient.uploadFile( fileBuffer, // file contents as a Buffer 'invoice.pdf', // filename 'application/pdf', // MIME type '58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier ); console.log(uploadedFile); // { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' } ``` | 参数 | 类型 | 描述 | | ---------------------------------- | -------- | ------------------------------------------------------------- | | `fileBuffer` | `Buffer` | 原始文件内容 | | `filename` | `string` | 文件名称(用于存储和显示) | | `contentType` | `string` | MIME type (defaults to `application/octet-stream` if omitted) | | `fieldMetadataUniversalIdentifier` | `string` | 你的对象上文件类型字段的 `universalIdentifier` | 关键点: * 使用字段的 `universalIdentifier`(而不是其工作区特定的 ID),因此你的上传代码可在安装了你的应用的任何工作区中运行。 * 返回的 `url` 是一个签名 URL,你可以用它来访问已上传的文件。 当你的代码在 Twenty 上运行(逻辑函数或前端组件)时,平台会以环境变量的形式注入凭据: * `TWENTY_API_URL`——Twenty API 的基础 URL * `TWENTY_APP_ACCESS_TOKEN` — Short-lived key scoped to your application's default function role 你无需将这些值传递给客户端——它们会自动从 `process.env` 读取。 API 密钥的权限由你的 `application-config.ts` 中 `defaultRoleUniversalIdentifier` 引用的角色决定。 ## Testing your app The SDK provides programmatic APIs that let you build, deploy, install, and uninstall your app from test code. Combined with [Vitest](https://vitest.dev/) and the typed API clients, you can write integration tests that verify your app works end-to-end against a real Twenty server. ### 设置 The scaffolded app already includes Vitest. If you set it up manually, install the dependencies: ```bash filename="Terminal" yarn add -D vitest vite-tsconfig-paths ``` Create a `vitest.config.ts` at the root of your app: ```ts vitest.config.ts import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [ tsconfigPaths({ projects: ['tsconfig.spec.json'], ignoreConfigErrors: true, }), ], test: { testTimeout: 120_000, hookTimeout: 120_000, include: ['src/**/*.integration-test.ts'], setupFiles: ['src/__tests__/setup-test.ts'], env: { TWENTY_API_URL: 'http://localhost:2020', TWENTY_API_KEY: 'your-api-key', }, }, }); ``` Create a setup file that verifies the server is reachable before tests run: ```ts src/__tests__/setup-test.ts import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { beforeAll } from 'vitest'; const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020'; const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test'); beforeAll(async () => { // Verify the server is running const response = await fetch(`${TWENTY_API_URL}/healthz`); if (!response.ok) { throw new Error( `Twenty server is not reachable at ${TWENTY_API_URL}. ` + 'Start the server before running integration tests.', ); } // Write a temporary config for the SDK fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true }); fs.writeFileSync( path.join(TEST_CONFIG_DIR, 'config.json'), JSON.stringify({ remotes: { local: { apiUrl: process.env.TWENTY_API_URL, apiKey: process.env.TWENTY_API_KEY, }, }, defaultRemote: 'local', }, null, 2), ); }); ``` ### Programmatic SDK APIs The `twenty-sdk/cli` subpath exports functions you can call directly from test code: | 函数 | 描述 | | -------------- | ------------------------------------------- | | `appBuild` | Build the app and optionally pack a tarball | | `appDeploy` | Upload a tarball to the server | | `appInstall` | Install the app on the active workspace | | `appUninstall` | Uninstall the app from the active workspace | Each function returns a result object with `success: boolean` and either `data` or `error`. ### Writing an integration test Here is a full example that builds, deploys, and installs the app, then verifies it appears in the workspace: ```ts src/__tests__/app-install.integration-test.ts import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'; import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'; import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const APP_PATH = process.cwd(); describe('App installation', () => { beforeAll(async () => { const buildResult = await appBuild({ appPath: APP_PATH, tarball: true, onProgress: (message: string) => console.log(`[build] ${message}`), }); if (!buildResult.success) { throw new Error(`Build failed: ${buildResult.error?.message}`); } const deployResult = await appDeploy({ tarballPath: buildResult.data.tarballPath!, onProgress: (message: string) => console.log(`[deploy] ${message}`), }); if (!deployResult.success) { throw new Error(`Deploy failed: ${deployResult.error?.message}`); } const installResult = await appInstall({ appPath: APP_PATH }); if (!installResult.success) { throw new Error(`Install failed: ${installResult.error?.message}`); } }); afterAll(async () => { await appUninstall({ appPath: APP_PATH }); }); it('should find the installed app in the workspace', async () => { const metadataClient = new MetadataApiClient(); const result = await metadataClient.query({ findManyApplications: { id: true, name: true, universalIdentifier: true, }, }); const installedApp = result.findManyApplications.find( (app: { universalIdentifier: string }) => app.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, ); expect(installedApp).toBeDefined(); }); }); ``` ### Running tests Make sure your local Twenty server is running, then: ```bash filename="Terminal" yarn test ``` Or in watch mode during development: ```bash filename="Terminal" yarn test:watch ``` ### Type checking You can also run type checking on your app without running tests: ```bash filename="Terminal" yarn twenty typecheck ``` This runs `tsc --noEmit` and reports any type errors. ## CLI 参考 Beyond `dev`, `build`, `add`, and `typecheck`, the CLI provides commands for executing functions, viewing logs, and managing app installations. ### Executing functions (`yarn twenty exec`) Run a logic function manually without triggering it via HTTP, cron, or database event: ```bash filename="Terminal" # Execute by function name yarn twenty exec -n create-new-post-card # Execute by universalIdentifier yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf # Pass a JSON payload yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}' # Execute pre-install or post-install functions yarn twenty exec --preInstall yarn twenty exec --postInstall ``` ### Viewing function logs (`yarn twenty logs`) Stream execution logs for your app's logic functions: ```bash filename="Terminal" # Stream all function logs yarn twenty logs # Filter by function name yarn twenty logs -n create-new-post-card # Filter by universalIdentifier yarn twenty logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf ``` This is different from `yarn twenty server logs`, which shows the Docker container logs. `yarn twenty logs` shows your app's function execution logs from the Twenty server. ### Uninstalling an app (`yarn twenty uninstall`) Remove your app from the active workspace: ```bash filename="Terminal" yarn twenty uninstall # Skip the confirmation prompt yarn twenty uninstall --yes ```