跳转到主要内容
逻辑函数是在 Twenty 平台上运行的服务端 TypeScript 函数。 它们可以由 HTTP 请求、cron 调度或数据库事件触发——也可以作为工具暴露给 AI 智能体。
每个函数文件都使用 defineLogicFunction() 导出包含处理程序和可选触发器的配置。
src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';

const handler = async (params: RoutePayload) => {
  const client = new CoreApiClient();
  const body = (params.body ?? {}) as { name?: string };
  const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? '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: 'POST',
    isAuthRequired: true,
  },
  /*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*.createdcompany.*
  • serverWebhook:从第三方服务(Stripe、GitHub、Svix 等)接收入站 Webhooks 在单个以注册为作用域的端点上,并从有效负载中解析目标工作区。 参见 服务端 Webhook 触发器
你也可以使用 CLI 手动执行函数:
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
你可以通过以下方式查看日志:
yarn twenty dev:function:logs

路由触发器负载

当路由触发器调用你的逻辑函数时,它会接收一个遵循 AWS HTTP API v2 格式RoutePayload 对象。 从 twenty-sdk/logic-function 导入 RoutePayload 类型:
import type { RoutePayload } from 'twenty-sdk/logic-function';

const handler = async (event: RoutePayload) => {
  const { headers, queryStringParameters, pathParameters, body } = event;
  const { method, path } = event.requestContext.http;

  return { message: 'Success' };
};
RoutePayload 类型具有以下结构:
属性类型描述示例
headersRecord\<string, string | undefined>HTTP 请求头(仅限 forwardedRequestHeaders 中列出的那些)见下文
queryStringParametersRecord\<string, string | undefined>查询字符串参数(多个值以逗号连接)/users?ids=1&ids=2&ids=3&name=Alice -> { ids: '1,2,3', name: 'Alice' }
pathParametersRecord\<string, string | undefined>从路由模式中提取的路径参数/users/:id/users/123 -> { id: '123' }
bodyobject | null已解析的请求体(JSON){ id: 1 } -> { id: 1 }
rawBodystring | undefined在 JSON 解析之前的原始 UTF-8 请求体。 用于验证 HMAC 风格的 Webhook 签名(例如 GitHub 的 X-Hub-Signature-256、Stripe)。 当运行时未保留它时为 undefined
isBase64Encodedboolean请求体是否为 base64 编码
requestContext.http.methodstringHTTP 方法(GET、POST、PUT、PATCH、DELETE)
requestContext.http.pathstring原始请求路径

forwardedRequestHeaders

出于安全原因,默认不会将传入请求的 HTTP 请求头传递给你的逻辑函数。 如需访问特定请求头,请在 forwardedRequestHeaders 数组中显式列出:
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'],
  },
});
在你的处理程序中,可以这样访问被转发的请求头:
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'])。

自定义 HTTP 响应

默认情况下,从处理程序返回一个普通值会以 200 响应返回该值(对象为 JSON,字符串为 text/plain)。 要控制状态码和响应头,请从 twenty-sdk/logic-function 返回一个 Response
import { Response } from 'twenty-sdk/logic-function';

const handler = async (event: RoutePayload) => {
  return new Response('<h1>Hello</h1>', {
    status: 201,
    headers: { 'content-type': 'text/html' },
  });
};
出于安全原因,响应头被限制在一个允许列表中。 任何不在该列表中的响应头(例如 Set-Cookie、CORS 响应头(如 Access-Control-Allow-Origin),或自定义的 X-* 响应头)都会在发送响应之前被静默丢弃。 允许的响应头包括:
  • content-type
  • content-language
  • content-disposition
  • cache-control
  • retry-after
状态码必须是有效的 HTTP 状态码(介于 100 和 599 之间)。 响应头名称的匹配不区分大小写。

服务端 Webhook 触发器

httpRouteTriggerSettings/s/ 下暴露一个函数,并根据请求主机解析 workspace——这在每个 workspace 都有自己域名时有效。 然而,第三方服务商会将每个租户的事件发送到同一个 webhook URL。 对于这种情况,请使用 serverWebhookTriggerSettings:该函数可在注册范围的端点访问,并且 workspace 将从负载中解析。
src/logic-functions/handle-provider-webhook.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { Response } from 'twenty-sdk/logic-function';

const handler = async (event: RoutePayload) => {
  // Verify the signature yourself before doing anything (see below).
  // Return a non-2xx Response to make the provider retry.
  return { received: true };
};

export default defineLogicFunction({
  universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
  name: 'handle-provider-webhook',
  handler,
  serverWebhookTriggerSettings: {
    workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
    forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
  },
});
该函数可在以下地址访问:
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
这两个标识符都是来自清单的 universalIdentifier——即应用注册的 universalIdentifier 和此逻辑函数的 universalIdentifier。 在服务商处注册该 URL。Workspace 解析。 由于一个端点为每个 workspace 提供服务,你的集成必须在传递内容中的某处放入目标 workspaceId,而 workspaceIdResolver.{ source, path } 用于告知平台从哪里读取它:
字段备注
来源body | query | headerbody 读取解析后的 JSON。 query 是最通用的——通常你可以控制所注册的回调 URL,因此可以追加 ?twentyWorkspaceId=…
路径点路径,例如 metadata.twentyWorkspaceId仅限字母数字 / _ / - 片段;原型键将被拒绝。
解析得到的值必须是有效的 workspace UUID,并且 你的应用必须已安装在该 workspace 中,否则请求会在函数运行前被拒绝。
签名验证由你负责。 平台不会为此触发器验证 webhook 签名——它只会解析 workspace 并运行你的函数。 你的处理程序必须使用 event.rawBody 以及你在 forwardedRequestHeaders 中列出的请求头自行验证签名,并与作为服务器/应用变量存储的密钥进行比对。 始终在产生任何副作用之前进行验证,并使用常量时间比较。
大多数服务商使用 HMAC-SHA256 进行签名;不同之处在于请求头名称、摘要编码方式以及被签名的负载字符串。 例如:
提供商要转发的请求头签名字符串摘要
Svix(Recall、Resend、Clerk)webhook-id, webhook-timestamp, webhook-signature{id}.{timestamp}.{rawBody}base64(密钥在去掉 whsec_ 前缀后为 base64)
Stripestripe-signature{timestamp}.{rawBody}hex
GitHubx-hub-signature-256{rawBody}hex(前缀为 sha256=
Shopifyx-shopify-hmac-sha256{rawBody}base64
Slackx-slack-signature, x-slack-request-timestampv0:{timestamp}:{rawBody}hex(前缀为 v0=
import { createHmac, timingSafeEqual } from 'crypto';

const handler = async (event: RoutePayload) => {
  const signature = event.headers['x-hub-signature-256'] ?? '';
  const expected =
    'sha256=' +
    createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
      .update(event.rawBody ?? '')
      .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);

  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response({ error: 'invalid signature' }, { status: 401 });
  }

  // ...handle the verified event
  return { received: true };
};
该函数同步运行,你返回的值会成为 HTTP 响应,因此服务商可以看到你的状态码,并在非 2xx 时重试。 保持处理程序足够快速——某些服务商(例如 Slack)会在几秒内超时。 由于函数在检查签名前运行,请在边缘通过速率限制来保护此端点。

数据库事件触发器有效负载

当数据库事件触发器调用你的逻辑函数时,每条被更改的记录都会对应一个 DatabaseEventPayload。 该负载将关于源工作区和对象的元数据与记录级事件组合在一起。
import type {
  DatabaseEventPayload,
  ObjectRecordCreateEvent,
  ObjectRecordDestroyEvent,
  ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';

type Person = {
  id: string;
  emails?: { primaryEmail?: string };
};
有效负载包括:
属性描述
name事件名称,例如 person.updated
workspaceId事件发生的工作区。
objectMetadata已更改对象的元数据。
recordId已更改记录的 ID。
userId, userWorkspaceId, workspaceMemberId当事件由工作区用户触发时的操作者字段。
properties事件的记录数据,根据操作不同,包含 beforeafterdiffupdatedFields
事件记录数据
person.createdevent.properties.after
person.updatedevent.properties.before, event.properties.after, event.properties.diff, event.properties.updatedFields
person.destroyedevent.properties.before
对于软删除,.deleted 遵循更新样式的结构,因为记录的 deletedAt 字段发生了变化。 对于永久删除,请使用 .destroyed
databaseEventTriggerSettings.updatedFields 会筛选出哪些更新事件会触发该函数。 event.properties.updatedFields 告诉你在当前事件中哪些字段实际发生了变化。
创建事件示例:
type PersonCreatedEvent = DatabaseEventPayload<
  ObjectRecordCreateEvent<Person>
>;

const handler = async (event: PersonCreatedEvent) => {
  const person = event.properties.after;

  return {
    personId: event.recordId,
    email: person.emails?.primaryEmail,
  };
};
更新事件示例:
type PersonUpdatedEvent = DatabaseEventPayload<
  ObjectRecordUpdateEvent<Person>
>;

const handler = async (event: PersonUpdatedEvent) => {
  const { before, after, diff, updatedFields } = event.properties;

  return {
    personId: event.recordId,
    updatedFields,
    previousEmail: before.emails?.primaryEmail,
    currentEmail: after.emails?.primaryEmail,
    emailDiff: diff.emails,
  };
};
仅在 email 更新时触发:
export default defineLogicFunction({
  ...,
  databaseEventTriggerSettings: {
    eventName: 'person.updated',
    updatedFields: ['emails'],
  },
});
销毁事件示例:
type PersonDestroyedEvent = DatabaseEventPayload<
  ObjectRecordDestroyEvent<Person>
>;

const handler = async (event: PersonDestroyedEvent) => {
  const personBeforeDestroy = event.properties.before;

  return {
    personId: event.recordId,
    email: personBeforeDestroy.emails?.primaryEmail,
  };
};

将函数公开为 AI 工具或工作流操作

逻辑函数可以在两个入口对外公开,每个入口都有各自的触发器:
  • toolTriggerSettings — 使该函数可被 Twenty 的 AI 功能(chat、MCP、function calling)发现。 使用标准 JSON Schema,LLM 能够原生理解的格式。
  • workflowActionTriggerSettings — 使该函数在可视化工作流构建器中显示为一个步骤。 使用 Twenty 丰富的 InputSchema,以便构建器可以呈现合适的字段编辑器、变量选择器和标签。
函数可以选择加入其中一个、另一个,或两者都加入。 它们与 cronTriggerSettingsdatabaseEventTriggerSettingshttpRouteTriggerSettings 并列 — 相同的模式、相同的结构。
src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
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,
  toolTriggerSettings: {},
});
关键点:
  • 函数可以混用这些入口 — 同时声明 toolTriggerSettingsworkflowActionTriggerSettings,即可在 chat 和工作流构建器中同时公开它。
  • toolTriggerSettings.inputSchemaworkflowActionTriggerSettings.inputSchema 均为可选。 如果省略,清单构建器会根据处理器源代码进行推断(AI 工具使用 JSON Schema,工作流操作使用 Twenty 的 InputSchema)。 当你需要更丰富的类型时,可显式提供一个 — 例如,在工作流构建器中使用对 FieldMetadataType 友好的字段(如 CURRENCYRELATION),或提供 AI 智能体可读取的 description 字段:
export default defineLogicFunction({
  ...,
  toolTriggerSettings: {
    inputSchema: {
      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'],
    },
  },
});
要只声明一次参数并服务于这两种界面,请定义一个单个 JSON Schema(InputJsonSchema),并使用来自 twenty-sdk/logic-functionjsonSchemaToInputSchema 将其转换为工作流操作所用。 toolTriggerSettings.inputSchema 直接接受 JSON Schema,而 workflowActionTriggerSettings.inputSchema 需要的是 Twenty 的 InputSchema
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';

const inputSchema: InputJsonSchema = {
  type: 'object',
  properties: {
    companyName: { type: 'string', label: 'Company name' },
    domain: { type: 'string', label: 'Domain' },
  },
  required: ['companyName'],
};

export default defineLogicFunction({
  ...,
  toolTriggerSettings: { inputSchema },
  workflowActionTriggerSettings: {
    label: 'Enrich Company',
    icon: 'IconBuilding',
    inputSchema: jsonSchemaToInputSchema(inputSchema),
  },
});
写一个好的 description AI 智能体会依赖该函数的 description 字段来决定何时使用该工具。 明确说明该工具的作用以及应在何时调用。
运行时辅助工具。 twenty-sdk/utils 会重新导出一些小型运行时辅助工具,这样处理程序就不需要直接从 twenty-shared 导入。 例如,isDefined(value)nullundefined 都会返回 false —— 使用它可以安全地收窄可选处理程序输入的类型,因为即使类型标注为 T | undefined,在运行时它们仍可能以 null 的形式传入:
import { isDefined } from 'twenty-sdk/utils';

const handler = async (params: { parentMessageId?: string }) => {
  if (isDefined(params.parentMessageId)) {
    // params.parentMessageId is narrowed to string here
  }
};
安装 hooks——预安装和后安装处理程序——共享此运行时,但使用它们自己的 define 函数进行声明,并且不接受触发器设置。 有关 definePreInstallLogicFunctiondefinePostInstallLogicFunction,请参阅 Install Hooks

类型化 API 客户端(twenty-client-sdk

twenty-client-sdk 包提供了两个类型化的 GraphQL 客户端,供你的逻辑函数和前端组件与 Twenty API 交互。
客户端导入端点是否生成?
CoreApiClienttwenty-client-sdk/core/graphql——工作区数据(记录、对象)是,在开发/构建时
MetadataApiClienttwenty-client-sdk/metadata/metadata——工作区配置、文件上传否,已预构建提供
CoreApiClient 是用于查询和变更工作区数据的主要客户端。 它会在执行 yarn twenty devyarn twenty dev:build根据你的工作区架构生成,因此具有完整的类型定义以匹配你的对象和字段。
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 在开发/构建时生成。 如果在未先运行 yarn twenty devyarn twenty dev:build 的情况下尝试使用它,将会抛出错误。 该生成过程是自动完成的——CLI 会自省你的工作区 GraphQL 架构,并使用 @genql/cli 生成类型化客户端。

使用 CoreSchema 进行类型标注

CoreSchema 提供与工作区对象相匹配的 TypeScript 类型,可用于为组件状态或函数参数进行类型标注:
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';

const [company, setCompany] = useState<
  Pick<CoreSchema.Company, 'id' | 'name'> | 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 一并提供,已预构建(无需生成)。 它会查询 /metadata 端点以获取工作区配置、应用和文件上传。
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 包含一个 uploadFile 方法,用于将文件附加到文件类型字段:
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://...' }
参数类型描述
fileBufferBuffer原始文件内容
filenamestring文件名称(用于存储和显示)
contentTypestringMIME 类型(如果省略,默认为 application/octet-stream
fieldMetadataUniversalIdentifierstring你的对象上文件类型字段的 universalIdentifier
关键点:
  • 使用字段的 universalIdentifier(而不是其工作区特定的 ID),因此你的上传代码可在安装了你的应用的任何工作区中运行。
  • 返回的 url 是一个签名 URL,你可以用它来访问已上传的文件。
当你的代码在 Twenty 上运行(逻辑函数或前端组件)时,平台会以环境变量的形式注入凭据:
  • TWENTY_API_URL——Twenty API 的基础 URL
  • TWENTY_APP_ACCESS_TOKEN——作用域限定为你的应用默认函数角色的短期密钥
你无需将这些值传递给客户端——它们会自动从 process.env 读取。 API 密钥的权限由使用 defineApplicationRole() 声明的角色(或在 application-config.ts 中通过 defaultRoleUniversalIdentifier 引用的角色)决定。