> ## Documentation Index
> Fetch the complete documentation index at: https://twenty-claude-cool-pascal-5ay683.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Логические функции

> Определяйте серверные функции на TypeScript с триггерами HTTP, cron и событиями базы данных.

Функции логики — это серверные функции на TypeScript, которые выполняются на платформе Twenty. Их можно запускать HTTP-запросами, расписаниями cron или событиями базы данных — а также предоставлять как инструменты для ИИ-агентов.

<AccordionGroup>
  <Accordion title="defineLogicFunction" description="Определяйте логические функции и их триггеры">
    Каждый файл функции использует `defineLogicFunction()` для экспорта конфигурации с обработчиком и необязательными триггерами.

    ```ts src/logic-functions/createPostCard.logic-function.ts theme={null}
    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**: Публикует вашу функцию по HTTP-пути и методу. В коде приложения добавляйте префикс `/s/` к пути маршрута при использовании `RestApiClient`; развернутый URL использует внедренную базовую часть `TWENTY_FUNCTIONS_URL` (или `\<server-url>/s`, если она не задана).

    <Note>
      Чтобы вызвать логическую функцию, запускаемую маршрутом, из фронтенд-компонента (без интерфейса), см. раздел [Вызов логической функции](/l/ru/developers/extend/apps/layout/front-components#calling-a-logic-function).
    </Note>

    * **cron**: Запускает вашу функцию по расписанию с использованием выражения CRON.
    * **databaseEvent**: Запускается при событиях жизненного цикла объектов рабочего пространства. Когда операция события — `updated`, можно указать конкретные поля для отслеживания в массиве `updatedFields`. Если оставить не заданным или пустым, любое обновление будет вызывать функцию.

    > например, `person.updated`, `*.created`, `company.*`

    * **serverRoute**: открывает один HTTP-маршрут в области регистрации. Функция-резолвер (объявленная с помощью `serverRouteTriggerSettings`) выполняется в рабочем пространстве-владельце и либо возвращает синхронный `Response`, либо целевое рабочее пространство И логическую функцию для постановки в очередь; в случае постановки в очередь платформа отправляет подтверждение с кодом `202` и запускает эту целевую функцию в очереди worker. См. [триггер серверного маршрута](#server-route-trigger).

    <Note>
      Вы также можете вручную выполнить функцию с помощью CLI:

      ```bash filename="Terminal" theme={null}
      yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
      ```

      ```bash filename="Terminal" theme={null}
      yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
      ```

      Вы можете просматривать логи с помощью:

      ```bash filename="Terminal" theme={null}
      yarn twenty dev:function:logs
      ```
    </Note>

    #### Полезная нагрузка триггера маршрута

    Когда триггер маршрута вызывает вашу логическую функцию, она получает объект `RoutePayload`, который соответствует [формату AWS HTTP API v2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
    Импортируйте тип `RoutePayload` из `twenty-sdk/logic-function`:

    ```ts theme={null}
    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` имеет следующую структуру:

    | Свойство                     | Тип                                    | Описание                                                                                                                                                                                                                | Пример                                                                     |
    | ---------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
    | `headers`                    | `Record\<string, string \| undefined>` | HTTP-заголовки (только перечисленные в `forwardedRequestHeaders`)                                                                                                                                                       | см. раздел ниже                                                            |
    | `queryStringParameters`      | `Record\<string, string \| undefined>` | Параметры строки запроса (несколько значений объединяются запятыми)                                                                                                                                                     | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
    | `pathParameters`             | `Record\<string, string \| undefined>` | Параметры пути, извлечённые из шаблона маршрута                                                                                                                                                                         | `/users/:id`, `/users/123` -> `{ id: '123' }`                              |
    | `body`                       | `object \| null`                       | Разобранное тело запроса (JSON)                                                                                                                                                                                         | `{ id: 1 }` -> `{ id: 1 }`                                                 |
    | `rawBody`                    | `string \| undefined`                  | Исходное тело запроса в кодировке UTF-8, до разбора JSON. Полезно для проверки подписей вебхуков в стиле HMAC (например, `X-Hub-Signature-256` от GitHub, Stripe). `undefined`, если среда выполнения не сохранила его. |                                                                            |
    | `isBase64Encoded`            | `boolean`                              | Является ли тело закодированным в base64                                                                                                                                                                                |                                                                            |
    | `requestContext.http.method` | `string`                               | Метод HTTP (GET, POST, PUT, PATCH, DELETE)                                                                                                                                                                              |                                                                            |
    | `requestContext.http.path`   | `string`                               | Необработанный путь запроса                                                                                                                                                                                             |                                                                            |

    #### forwardedRequestHeaders

    По умолчанию HTTP-заголовки из входящих запросов **не** передаются в вашу логическую функцию по соображениям безопасности.
    Чтобы получить доступ к определённым заголовкам, перечислите их в массиве `forwardedRequestHeaders`:

    ```ts theme={null}
    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 theme={null}
    const handler = async (event: RoutePayload) => {
      const signature = event.headers['x-webhook-signature'];
      const contentType = event.headers['content-type'];

      // Validate webhook signature...
      return { received: true };
    };
    ```

    <Note>
      Имена заголовков приводятся к нижнему регистру. Обращайтесь к ним, используя ключи в нижнем регистре (например, `event.headers['content-type']`).
    </Note>

    #### Пользовательский HTTP-ответ

    По умолчанию возврат простого значения из обработчика отправляет его обратно как ответ `200` (JSON для объектов, `text/plain` для строк). Чтобы управлять статус-кодом и заголовками ответа, верните `Response` из `twenty-sdk/logic-function`:

    ```ts theme={null}
    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`

    <Note>
      Код состояния должен быть допустимым кодом состояния HTTP (в диапазоне от 100 до 599). Имена заголовков ответа сравниваются без учета регистра.
    </Note>

    #### Ошибочные ответы платформы

    Помимо собственных ответов вашего обработчика, платформа в некоторых ситуациях отвечает на запросы к маршрутам напрямую: `404`, когда маршрут или функция не существует, `403`, когда приложение остановлено, `429`, когда достигнут предел частоты выполнения, и `422`, когда `dependencies` приложения для продакшена слишком велики для установки — см. раздел [ограничения на размер зависимостей](/l/ru/developers/extend/apps/getting-started/project-structure#dependencies).

    #### Триггер серверного маршрута

    `httpRouteTriggerSettings` предоставляет функцию по пути `/s/` и определяет рабочее пространство из хоста запроса — это работает, когда у каждого рабочего пространства свой домен. Поставщики сторонних сервисов, однако, отправляют события всех арендаторов на **один** URL. В этом случае используйте `serverRouteTriggerSettings`.

    Триггер состоит из двух частей:

    1. Логическая функция-**резолвер** — объявляется с помощью `serverRouteTriggerSettings` — выполняется в вашем **рабочем пространстве-владельце** (рабочем пространстве, которому принадлежит регистрация приложения). Она анализирует входящий запрос и возвращает либо:

       * `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — платформа ставит эту целевую функцию в очередь в определённом рабочем пространстве и отправляет подтверждение с кодом `202 { queued: true }`, или
       * `Response` из `twenty-sdk/logic-function` — платформа возвращает этот HTTP‑ответ **синхронно** и **не** ставит целевую функцию в очередь (используйте это для challenge‑рукопожатий, таких как Slack `url_verification`).

       Резолвер является единой точкой авторизации — URL содержит только идентификатор резолвера. **Это предпочтительное место для проверки подписей запросов**: резолвер выполняется до любых побочных эффектов, имеет доступ к исходным `rawBody` и переадресованным заголовкам и может отклонить запрос, не обращаясь к целевой функции.
    2. **Целевая** логическая функция — обычная логическая функция на рабочее пространство — затем выполняется в определенном рабочем пространстве с полезной нагрузкой, возвращенной резолвером (или с исходной полезной нагрузкой запроса, если резолвер ее не преобразовал). Его возвращаемое значение не видит HTTP‑клиент, когда резолвер выбрал путь постановки в очередь.

    ```ts src/logic-functions/resolve-server-route.logic-function.ts theme={null}
    import { createHmac, timingSafeEqual } from 'crypto';
    import { defineLogicFunction } from 'twenty-sdk/define';
    import { Response, type RoutePayload } from 'twenty-sdk/logic-function';

    // Runs in the owner workspace. Verifies the request signature, picks
    // which target function should handle the event, and returns the
    // workspace + target the platform should dispatch to.
    const handler = async (event: RoutePayload) => {
      // Fail closed if the secret isn't configured — never fall back to an
      // empty key, which would let any caller forge a matching signature.
      const secret = process.env.GITHUB_WEBHOOK_SECRET;

      if (!secret) {
        throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
      }

      const signature = event.headers['x-hub-signature-256'] ?? '';
      const expected =
        'sha256=' +
        createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');

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

      if (a.length !== b.length || !timingSafeEqual(a, b)) {
        throw new Error('invalid signature');
      }

      const body = (event.body ?? {}) as {
        challenge?: string;
        metadata?: { twentyWorkspaceId?: string };
        type?: string;
      };

      // Handshakes must be answered on this same response, so reply from the
      // resolver instead of returning a dispatch target.
      if (body.type === 'url_verification') {
        return new Response({ challenge: body.challenge });
      }

      const workspaceId = body.metadata?.twentyWorkspaceId;

      if (!workspaceId) {
        throw new Error('event is not linked to a workspace');
      }

      return {
        workspaceId,
        // Route different event types to different target functions.
        targetLogicFunctionUniversalIdentifier:
          body.type === 'invoice.paid'
            ? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
            : 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
      };
    };

    export default defineLogicFunction({
      universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
      name: 'resolve-server-route',
      handler,
      serverRouteTriggerSettings: {
        forwardedRequestHeaders: ['x-hub-signature-256'],
      },
    });
    ```

    ```ts src/logic-functions/handle-invoice-paid.logic-function.ts theme={null}
    import { defineLogicFunction } from 'twenty-sdk/define';
    import type { RoutePayload } from 'twenty-sdk/logic-function';

    // Runs in the resolved workspace. The resolver has already authenticated
    // the request, so this handler can focus on the actual work.
    const handler = async (event: RoutePayload) => {
      // ...handle the verified event
      return { received: true };
    };

    export default defineLogicFunction({
      universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
      name: 'handle-invoice-paid',
      handler,
    });
    ```

    Конечная точка доступна по адресу:

    ```
    POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
    ```

    Идентификатор — это `universalIdentifier` резолвера из вашего манифеста. Зарегистрируйте этот URL у поставщика.

    **Ответ на проверочный `GET`.** Некоторые поставщики проверяют конечную точку перед отправкой данных на неё: они отправляют `GET` с проверочным запросом на тот же URL, на который позднее будут `POST` события. Meta's WhatsApp Cloud API — один из них. Маршрут сервера отвечает только на `POST`, если не указано иное, поэтому объявите оба метода:

    ```ts theme={null}
    serverRouteTriggerSettings: {
      forwardedRequestHeaders: ['x-hub-signature-256'],
      httpMethods: ['GET', 'POST'],
    }
    ```

    Проверочный запрос поступает в `event.queryStringParameters`, а возврат `Response` отправляет его обратно поставщику в рамках того же запроса. Строковое тело отправляется как `text/plain`, что и ожидают эти поставщики:

    ```ts theme={null}
    const handler = async (event: RoutePayload) => {
      if (event.requestContext.http.method === 'GET') {
        return new Response(event.queryStringParameters['hub.challenge']);
      }
      // …handle the delivered event
    };
    ```

    `httpMethods` заменяет значение по умолчанию, а не дополняет его, поэтому одного `['GET']` достаточно, чтобы маршрут отклонял `POST`. Поддерживаются только `GET` и `POST`. Не задавайте его, если провайдеру не требуется второй метод: резолвер маршрута, объявляющего `GET`, будет выполняться при обращении любого неаутентифицированного вызывающего клиента, включая поисковые роботы и средства предварительного просмотра ссылок, которые отправляют `GET` без запроса. На всё, для чего у платформы нет метода, возвращается `405`, при этом резолвер вообще не запускается.

    <Note>
      **Приложение должно быть закреплено и установлено в рабочем пространстве владельца.** Поскольку резолвер выполняется в **рабочем пространстве владельца** (рабочем пространстве, которому принадлежит регистрация приложения), триггер серверного маршрута будет работать только после того, как приложение будет *закреплено* — то есть у него появится рабочее пространство владельца — **и** это приложение будет **установлено в рабочем пространстве владельца**. Пока оба этих условия не выполнены, резолверу негде выполняться, поэтому маршрут не может быть отправлен на обработку. Приложение, которое предоставляет логическую функцию `serverRouteTriggerSettings`, соответственно, не может быть размещено в маркетплейсе, пока оно не будет закреплено и установлено в рабочем пространстве владельца.
    </Note>

    **Контракт резолвера.** Тип `LogicFunctionConfig` в SDK обеспечивает это на этапе компиляции: как только вы задаете `serverRouteTriggerSettings`, ваш обработчик обязан возвращать либо `Response`, либо `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (или `Promise` одного из этих вариантов). На пути диспетчеризации `workspaceId` должен указывать на рабочее пространство, в котором установлена целевая функция, иначе запрос будет отклонен с кодом `404`. Результат, не соответствующий ни одному из этих форматов — включая случай, когда идентификаторы не являются UUID, — отклоняется с кодом `502`.

    | Поле                                     | Тип                       | Заметки                                                                                      |
    | ---------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------- |
    | `workspaceId`                            | `string`                  | UUID рабочего пространства, в котором будет выполняться целевая функция.                     |
    | `targetLogicFunctionUniversalIdentifier` | `string`                  | `universalIdentifier` логической функции, которую нужно вызвать в этом рабочем пространстве. |
    | `payload`                                | `object` (необязательный) | Если задан, заменяет тело запроса, отправляемое целевой функции.                             |

    <Warning>
      **Ответственность за проверку подписи лежит на вас — выполняйте проверку в резолвере.** Платформа не проверяет подписи запросов. Резолвер — рекомендуемое место для этого: он выполняется первым, имеет доступ к `event.rawBody` и заголовкам, которые вы указали в `forwardedRequestHeaders`, и выброшенная ошибка (или любой `workspaceId`, не соответствующий ожидаемому) останавливает диспетчеризацию до вызова целевой функции. Если вместо этого вы перенесете проверку в целевую функцию, целевая функция должна позаботиться о том, чтобы не потерять `rawBody` и заголовки — то есть резолвер не должен возвращать `payload`. Всегда выполняйте проверку **до** любых побочных эффектов и используйте сравнение с постоянным временем выполнения.
    </Warning>

    Для подписей запросов большинство провайдеров используют HMAC-SHA256; различаются имя заголовка, кодировка дайджеста и строка подписываемой полезной нагрузки. Несколько примеров:

    | Провайдер                    | Заголовки для пересылки                                | Подписываемая строка         | Дайджест                                                          |
    | ---------------------------- | ------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------- |
    | Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (секрет в формате base64 после удаления префикса `whsec_`) |
    | Stripe                       | `stripe-signature`                                     | `{timestamp}.{rawBody}`      | hex                                                               |
    | GitHub                       | `x-hub-signature-256`                                  | `{rawBody}`                  | hex (с префиксом `sha256=`)                                       |
    | Shopify                      | `x-shopify-hmac-sha256`                                | `{rawBody}`                  | base64                                                            |
    | Слэк                         | `x-slack-signature`, `x-slack-request-timestamp`       | `v0:{timestamp}:{rawBody}`   | hex (с префиксом `v0=`)                                           |

    Приведенный выше пример резолвера уже показывает поток GitHub HMAC-SHA256 — адаптируйте имя заголовка, кодировку дайджеста и строку подписываемой полезной нагрузки в соответствии с провайдером, с которым вы интегрируетесь.

    <Note>
      Когда резолвер возвращает объект диспетчеризации, маршрут отвечает `202 { queued: true }`, а целевая функция запускается в очереди worker — вызывающая сторона никогда не видит задержку, результат или сбои целевой функции (они записываются в журналах выполнения). Это не позволяет повторным отправкам со стороны отправителя усиливать замедление обработки, что и требуется для приёма вебхуков.

      Когда вызывающей стороне нужно прочитать тело ответа в рамках того же запроса (challenge‑рукопожатия, интерактивные подтверждения), вместо этого верните `Response` из **резолвера**. Платформа синхронно эхо‑возвращает этот ответ и пропускает очередь; его заголовки проходят через тот же allow‑list, что и заголовки ответов HTTP‑маршрутов. Делайте функцию-резолвер быстрой — некоторые провайдеры (например, Slack) прерывают запрос через несколько секунд. Поскольку резолвер доступен как публичная конечная точка, защитите его с помощью ограничения частоты запросов (rate limiting) на вашем периметре (edge).
    </Note>

    #### Полезная нагрузка триггера события базы данных

    Когда триггер события базы данных вызывает вашу функцию логики, она получает по одному `DatabaseEventPayload` на каждую изменённую запись. Полезная нагрузка объединяет метаданные о рабочем пространстве-источнике и объекте с событием на уровне записи.

    ```ts theme={null}
    import type {
      DatabaseEventPayload,
      ObjectRecordCreateEvent,
      ObjectRecordDestroyEvent,
      ObjectRecordUpdateEvent,
    } from 'twenty-sdk/logic-function';

    type Person = {
      id: string;
      emails?: { primaryEmail?: string };
    };
    ```

    Полезная нагрузка включает:

    | Свойство                                         | Описание                                                                                           |
    | ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
    | `name`                                           | Имя события, например `person.updated`.                                                            |
    | `workspaceId`                                    | Рабочее пространство, в котором произошло событие.                                                 |
    | `objectMetadata`                                 | Метаданные для объекта, который изменился.                                                         |
    | `recordId`                                       | Идентификатор измененной записи.                                                                   |
    | `userId`, `userWorkspaceId`, `workspaceMemberId` | Поля инициатора, если событие было вызвано пользователем рабочего пространства.                    |
    | `properties`                                     | Данные записи для события с `before`, `after`, `diff` и `updatedFields` в зависимости от операции. |

    | Событие            | Данные записи                                                                                                  |
    | ------------------ | -------------------------------------------------------------------------------------------------------------- |
    | `person.created`   | `event.properties.after`                                                                                       |
    | `person.updated`   | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
    | `person.destroyed` | `event.properties.before`                                                                                      |

    При логическом удалении `.deleted` имеет формат обновления, поскольку изменяется поле `deletedAt` записи.
    Для окончательного удаления используйте `.destroyed`.

    <Note>
      `databaseEventTriggerSettings.updatedFields` фильтрует, какие события обновления запускают функцию.
      `event.properties.updatedFields` указывает, какие поля фактически изменились в текущем событии.
    </Note>

    Пример события создания:

    ```ts theme={null}
    type PersonCreatedEvent = DatabaseEventPayload<
      ObjectRecordCreateEvent<Person>
    >;

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

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

    Пример события обновления:

    ```ts theme={null}
    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:

    ```ts theme={null}
    export default defineLogicFunction({
      ...,
      databaseEventTriggerSettings: {
        eventName: 'person.updated',
        updatedFields: ['emails'],
      },
    });
    ```

    Пример события уничтожения:

    ```ts theme={null}
    type PersonDestroyedEvent = DatabaseEventPayload<
      ObjectRecordDestroyEvent<Person>
    >;

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

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

    #### Контекст выполнения

    Каждый обработчик получает второй аргумент, описывающий сам запуск независимо от того, что его вызвало. Если форма первого аргумента меняется в зависимости от триггера, то форма этого не меняется:

    ```ts theme={null}
    import type { LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';

    const handler = async (
      event: DatabaseEventPayload,
      { workspaceId, workspaceMemberId }: LogicFunctionExecutionContext,
    ) => {
      if (workspaceMemberId === null) {
        throw new Error('This function must be triggered by a person');
      }

      return { reviewedBy: workspaceMemberId, workspaceId };
    };
    ```

    | Поле                | Тип              | Описание                                                                                          |
    | ------------------- | ---------------- | ------------------------------------------------------------------------------------------------- |
    | `workspaceId`       | `string`         | Рабочее пространство, к которому относится запуск.                                                |
    | `userWorkspaceId`   | `string \| null` | Человек, вызвавший запуск, представленный своим участием в этом рабочем пространстве.             |
    | `workspaceMemberId` | `string \| null` | Тот же человек в виде записи участника рабочего пространства, к которой привязываются записи.     |
    | `retryCount`        | `number`         | `0` при первой попытке. См. [Фоновые задачи](/l/ru/developers/extend/apps/logic/background-jobs). |
    | `maxRetries`        | `number`         | Верхняя граница количества повторных попыток, запрошенных приложением.                            |

    `userWorkspaceId` и `workspaceMemberId` имеют значение `null`, когда запуск никто не вызвал: у cron-расписаний, хуков установки и неаутентифицированных вебхуков нет человека, стоящего за ними. Они также имеют значение `null`, когда у человека нет записи участника рабочего пространства или когда она была удалена.

    <Note>
      **Контекст сообщает, кто вызвал запуск.** То, что запуск может *делать*, определяется отдельно, ниже.
    </Note>

    #### Чьи права доступа использует вызов

    Каждый клиент — `CoreApiClient`, `MetadataApiClient` и `RestApiClient` — действует от имени человека, вызвавшего запуск: его роль пересекается с ролью вашего приложения, поэтому вызов никогда не может сделать больше, чем позволяет любая из этих ролей. Это поведение по умолчанию, и оно означает, что человек никогда не сможет использовать ваше приложение для превышения собственных разрешений.

    Когда запуск никто не вызвал, нет человека, от имени которого можно действовать, поэтому тот же клиент использует права доступа самого приложения: cron-расписания, хуки установки и неаутентифицированные вебхуки не требуют специальной обработки.

    Для некоторых вызовов правомерно требуются права доступа самого приложения, даже когда за запуском *стоит* человек — например, для чтения записей конфигурации вашего приложения или выполнения работы, которую этот человек не мог бы выполнить сам. Создайте для них второй клиент:

    ```ts theme={null}
    import { CoreApiClient } from 'twenty-client-sdk/core';

    const asPerson = new CoreApiClient();
    const asApplication = new CoreApiClient({ runAs: 'application' });

    const handler = async (event: DatabaseEventPayload) => {
      // config records only your app may read
      const { slackUserLinks } = await asApplication.query({ ... });

      // created as the person, so it is attributed and permission-checked
      await asPerson.mutation({ createNote: { ... } });
    };
    ```

    Создайте оба клиента один раз в области видимости модуля, и тогда каждый сайт вызова будет указывать используемые права доступа через вызываемый клиент. `RestApiClient` принимает тот же параметр:

    ```ts theme={null}
    import { RestApiClient } from 'twenty-client-sdk/rest';

    const asApplication = new RestApiClient({ runAs: 'application' });
    ```

    <Note>
      **Запуск, который никто не вызвал, действует как ваше приложение.** У cron-расписаний, хуков установки и неаутентифицированных вебхуков нет человека, стоящего за ними, поэтому клиент по умолчанию использует права доступа самого приложения и продолжает работать. `runAs: 'application'` нужен только тогда, когда вам требуются эти права доступа в запуске, который *вызвал* человек.

      Проверяйте `context.workspaceMemberId`, когда функция ведет себя по-разному в зависимости от того, стоит ли за ней кто-то, например чтобы указать автора записи.
    </Note>

    Помощники SDK, обращающиеся к собственным ресурсам вашего приложения, всегда используют его права доступа и игнорируют `runAs`: [хранилище ключей и значений](/l/ru/developers/extend/apps/logic/key-value-store), [подключения](/l/ru/developers/extend/apps/logic/connections), `runAgent`, `getPublicAssetUrl` и списание кредитов.

    #### Предоставление функции в качестве инструмента ИИ или действия рабочего процесса

    Функции логики могут быть представлены в двух интерфейсах, у каждого — свой триггер:

    * **`toolTriggerSettings`** — делает функцию обнаруживаемой для возможностей ИИ Twenty (чат, MCP, вызов функций). Использует стандартную JSON Schema — формат, который модели LLM изначально понимают.
    * **`workflowActionTriggerSettings`** — делает функцию доступной как шаг в визуальном конструкторе рабочих процессов. Использует расширенную `InputSchema` от Twenty, чтобы конструктор мог отрисовывать корректные редакторы полей, селекторы переменных и подписи.

    Функция может выбрать один, другой или оба варианта. Они идут рядом с `cronTriggerSettings`, `databaseEventTriggerSettings` и `httpRouteTriggerSettings` — тот же шаблон, та же структура.

    <Note>
      **Связь с действием Code рабочего процесса.** Встроенное действие **Code** в конструкторе рабочих процессов само по себе является логической функцией — Twenty создаёт по одной на каждый шаг Code и отображает его редактор встроенным образом. `workflowActionTriggerSettings` — это способ превратить разовый встроенный код в **повторно используемое** действие: определите функцию один раз в своём приложении, и она станет доступной для выбора в любом рабочем процессе, вместо копирования и вставки в каждый шаг Code. См. [действие Code](/l/ru/user-guide/workflows/capabilities/workflow-actions#code) в руководстве пользователя, чтобы увидеть, как это выглядит для конечного пользователя.
    </Note>

    ```ts src/logic-functions/enrich-company.logic-function.ts theme={null}
    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: {},
    });
    ```

    Основные моменты:

    * Функция может сочетать интерфейсы — объявите и `toolTriggerSettings`, и `workflowActionTriggerSettings`, чтобы сделать её доступной и в чате, и в конструкторе рабочих процессов.
    * `toolTriggerSettings.inputSchema` и `workflowActionTriggerSettings.inputSchema` — обе необязательны. Если они опущены, конструктор манифеста выводит их из исходного кода обработчика (JSON Schema — для инструмента ИИ, `InputSchema` от Twenty — для действия рабочего процесса). Укажите её явно, когда вам нужна более богатая типизация — например, с полями, учитывающими `FieldMetadataType`, такими как `CURRENCY` или `RELATION`, для конструктора рабочих процессов, или с полями `description`, которые может прочитать ИИ-агент:

    ```ts theme={null}
    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`) и преобразуйте её для действия рабочего процесса с помощью `jsonSchemaToInputSchema` из `twenty-sdk/logic-function`. `toolTriggerSettings.inputSchema` принимает JSON Schema напрямую, в то время как `workflowActionTriggerSettings.inputSchema` ожидает `InputSchema` Twenty:

    ```ts theme={null}
    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),
      },
    });
    ```

    ##### Полный пример действия рабочего процесса

    `workflowActionTriggerSettings` принимает четыре поля:

    | Поле           | Назначение                                                                                                                                                                                                    |
    | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `label`        | Имя, отображаемое для действия в селекторе шагов конструктора рабочих процессов. По умолчанию используется `name` функции.                                                                                    |
    | `icon`         | Иконка, отображаемая рядом с действием (имя из `tabler-icons`, например, `IconBuilding`).                                                                                                                     |
    | `inputSchema`  | Расширенная схема ввода (`InputSchema`) Twenty — то, что конструктор отображает как настраиваемые поля (с выбором переменных). Необязательно; при отсутствии выводится из обработчика.                        |
    | `outputSchema` | Определяет структуру, которую возвращает обработчик, чтобы **последующие шаги могли сопоставлять свои данные с его выходными полями**. Необязательно; без неё вывод представлен одним непрозрачным значением. |

    Объединяя всё вместе — функция, представленная как действие рабочего процесса, с объявленным выходом, чтобы последующие шаги могли ссылаться на `taskId`:

    ```ts src/logic-functions/enrich-company.logic-function.ts theme={null}
    import { defineLogicFunction } from 'twenty-sdk/define';
    import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
    import { CoreApiClient } from 'twenty-client-sdk/core';

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

    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,
        },
      });

      // The keys returned here should match the `outputSchema` properties below.
      return { taskId: result.createTask.id, enriched: true };
    };

    export default defineLogicFunction({
      universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
      name: 'enrich-company',
      description: 'Enrich a company record with external data',
      timeoutSeconds: 10,
      handler,
      workflowActionTriggerSettings: {
        label: 'Enrich Company',
        icon: 'IconBuilding',
        inputSchema: jsonSchemaToInputSchema(inputSchema),
        outputSchema: [
          {
            type: 'object',
            properties: {
              taskId: { type: 'string' },
              enriched: { type: 'boolean' },
            },
          },
        ],
      },
    });
    ```

    После установки приложения **Enrich Company** появляется в селекторе действий конструктора рабочих процессов. Конструктор отображает `companyName` и `domain` как поля ввода (каждое может получать значения из предыдущих шагов), а последующие шаги могут ссылаться на выходные значения шага `taskId` и `enriched`.

    <Note>
      **Напишите хорошее описание в поле `description`.** Агенты ИИ опираются на поле `description` функции, чтобы решить, когда использовать инструмент. Чётко опишите, что делает инструмент и когда его следует вызывать.
    </Note>
  </Accordion>
</AccordionGroup>

<Note>
  **Вспомогательные функции времени выполнения.** `twenty-sdk/utils` повторно экспортирует небольшие вспомогательные функции времени выполнения, поэтому обработчики никогда не импортируют напрямую из `twenty-shared`. Например, `isDefined(value)` возвращает `false` как для `null`, так и для `undefined` — используйте её, чтобы безопасно сузить необязательные входные данные обработчика, которые могут приходить как `null` во время выполнения, даже если имеют тип `T | undefined`:

  ```ts theme={null}
  import { isDefined } from 'twenty-sdk/utils';

  const handler = async (params: { parentMessageId?: string }) => {
    if (isDefined(params.parentMessageId)) {
      // params.parentMessageId is narrowed to string here
    }
  };
  ```
</Note>

<Note>
  **Хуки установки** — обработчики до установки, после установки и при удалении — используют тот же рантайм, но объявляются с помощью собственных функций `define` и не принимают настройки триггеров. См. раздел [Install Hooks](/l/ru/developers/extend/apps/config/install-hooks) для `definePreInstallLogicFunction`, `definePostInstallLogicFunction` и `defineUninstallLogicFunction`.
</Note>

## Создание действия на временной шкале.

Используйте `createTimelineActivity()` для публикации явного события домена из логической функции. Сначала определите событие как [тип действия на временной шкале](/l/ru/developers/extend/apps/data/timeline-activity-types), затем укажите тип и объекты с помощью их стабильных универсальных идентификаторов:

```ts theme={null}
import { createTimelineActivity } from 'twenty-sdk/logic-function';

await createTimelineActivity({
  timelineActivityTypeUniversalIdentifier:
    'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
  targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
  targetRecordId: postCardId,
  happensAt: new Date().toISOString(),
  properties: {
    deliveryProvider: 'postal-service',
  },
});
```

Twenty преобразует универсальные идентификаторы в идентификаторы метаданных, специфичные для установки, проверяет, что тип действия на временной шкале принадлежит вызывающему приложению, и сохраняет снимок его метаданных представления в новом действии.

Обязательные входные параметры: `timelineActivityTypeUniversalIdentifier`, `targetObjectUniversalIdentifier` и `targetRecordId`. Также можно указать `happensAt`, `properties` и `workspaceMemberId`. `happensAt` управляет положением события и отображаемым временем на временной шкале; по умолчанию используется время создания.

Чтобы связать с событием другую запись, укажите вместе `linkedRecordId` и `linkedObjectMetadataUniversalIdentifier`. Кроме того, можно указать `linkedRecordCachedName` в качестве резервного варианта отображения исторического имени:

```ts theme={null}
await createTimelineActivity({
  timelineActivityTypeUniversalIdentifier:
    'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
  targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
  targetRecordId: postCardId,
  linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
  linkedRecordId: recipientId,
  linkedRecordCachedName: recipientName,
});
```

Роли логической функции требуется разрешение на запись в стандартный объект `timelineActivity`. Не связывайте явные типы событий с `action`; тип, связанный с действием, уже получает автоматические события аудита и в противном случае создаст дублирующиеся строки.

## Типизированные клиенты API (twenty-client-sdk)

Пакет `twenty-client-sdk` предоставляет два типизированных клиента GraphQL для взаимодействия с API Twenty из ваших логических функций и фронт-компонентов.

| Клиент              | Импорт                       | Конечная точка                                                    | Генерируется?                    |
| ------------------- | ---------------------------- | ----------------------------------------------------------------- | -------------------------------- |
| `CoreApiClient`     | `twenty-client-sdk/core`     | `/graphql` — данные рабочего пространства (записи, объекты)       | Да, на этапе dev/build           |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — конфигурация рабочего пространства, загрузка файлов | Нет, поставляется в готовом виде |

<AccordionGroup>
  <Accordion title="CoreApiClient" description="Запрос и изменение данных рабочего пространства (записи, объекты)">
    `CoreApiClient` — основной клиент для запросов и изменений данных рабочего пространства. Он **генерируется из схемы вашего рабочего пространства** во время `yarn twenty dev` или `yarn twenty dev:build`, поэтому полностью типизирован в соответствии с вашими объектами и полями.

    ```ts theme={null}
    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,
      },
    });
    ```

    Клиент использует синтаксис selection-set: передайте `true`, чтобы включить поле, используйте `__args` для аргументов и вкладывайте объекты для отношений. Вы получаете полное автодополнение и проверку типов на основе схемы вашего рабочего пространства.

    <Note>
      **CoreApiClient генерируется на этапе dev/build.** Если вы используете его, не запустив сначала `yarn twenty dev` или `yarn twenty dev:build`, он выбросит ошибку. Генерация происходит автоматически — CLI анализирует GraphQL-схему вашего рабочего пространства и создает типизированный клиент с помощью `@genql/cli`.
    </Note>

    #### Использование CoreSchema для аннотаций типов

    `CoreSchema` предоставляет типы TypeScript, соответствующие объектам вашего рабочего пространства — это полезно для типизации состояния компонентов или параметров функций:

    ```ts theme={null}
    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);
    ```
  </Accordion>

  <Accordion title="MetadataApiClient" description="Конфигурация рабочего пространства, приложения и загрузка файлов">
    `MetadataApiClient` поставляется в готовом виде вместе с SDK (генерация не требуется). Он выполняет запросы к эндпоинту `/metadata` для получения конфигурации рабочего пространства, приложений и загрузки файлов. Он принимает тот же параметр `runAs`, что и `CoreApiClient` — см. [Чьи права доступа использует вызов](#whose-access-a-call-uses).

    ```ts theme={null}
    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` для прикрепления файлов к полям типа файла:

    ```ts theme={null}
    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 (по умолчанию `application/octet-stream`, если не указан) |
    | `fieldMetadataUniversalIdentifier` | `string` | Значение `universalIdentifier` для поля типа файла в вашем объекте |

    Основные моменты:

    * Он использует `universalIdentifier` поля (а не его идентификатор, специфичный для рабочего пространства), поэтому ваш код загрузки будет работать в любом рабочем пространстве, где установлено ваше приложение.
    * Возвращаемый `url` — это подписанный URL, который можно использовать для доступа к загруженному файлу.
  </Accordion>
</AccordionGroup>

<Note>
  Когда ваш код выполняется на Twenty (логические функции или фронт-компоненты), платформа предоставляет учётные данные в виде переменных окружения:

  * `TWENTY_API_URL` — базовый URL API Twenty
  * `TWENTY_APP_ACCESS_TOKEN` — краткоживущий ключ для прав доступа по умолчанию: роль человека пересекается с ролью вашего приложения, когда за запуском кто-то стоит; если никто не стоит — используется собственная роль вашего приложения. В логической функции человек — это тот, кто вызвал запуск, а во фронтенд-компоненте — тот, кто просматривает страницу.
  * `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — краткоживущий ключ, ограниченный исключительно собственной ролью вашего приложения. Только для логических функций, всегда внедряется в них и используется `runAs: 'application'`.

  Вам **не** нужно передавать их клиентам — они автоматически считываются из `process.env`, а в разделе [Чьи права доступа использует вызов](#whose-access-a-call-uses) описан выбор между ними. Собственные разрешения вашего приложения определяются ролью, объявленной с помощью `defineApplicationRole()` (или указанной через `defaultRoleUniversalIdentifier` в `application-config.ts`); запуск, действующий от имени человека, никогда не может превысить ни эту роль, ни его собственную.
</Note>
