> ## 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.

# 로직 함수

> HTTP, cron 및 데이터베이스 이벤트 트리거를 사용하여 서버 측 TypeScript 함수를 정의합니다.

로직 함수는 Twenty 플랫폼에서 실행되는 서버 측 TypeScript 함수입니다. 이 함수들은 HTTP 요청, cron 스케줄 또는 데이터베이스 이벤트에 의해 트리거될 수 있으며 — AI 에이전트를 위한 도구로도 제공할 수 있습니다.

<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 경로와 메서드로 함수를 노출합니다. 앱 코드에서 `RestApiClient`를 사용할 때는 라우트 경로 앞에 `/s/`를 접두사로 붙이십시오. 배포된 URL은 주입된 `TWENTY_FUNCTIONS_URL` 기본 URL(설정되지 않은 경우 `\<server-url>/s`)을 사용합니다.

    <Note>
      (헤드리스) 프런트 컴포넌트에서 라우트로 트리거되는 로직 함수를 호출하려면 [로직 함수 호출하기](/l/ko/developers/extend/apps/layout/front-components#calling-a-logic-function)를 참고하세요.
    </Note>

    * **cron**: CRON 식을 사용하여 예약된 일정으로 함수를 실행합니다.
    * **databaseEvent**: 워크스페이스 객체 라이프사이클 이벤트에서 실행됩니다. 이벤트 작업이 `updated`인 경우, 수신할 특정 필드를 `updatedFields` 배열에 지정할 수 있습니다. 정의하지 않거나 비워두면, 어떤 업데이트든 함수가 트리거됩니다.

    > 예: `person.updated`, `*.created`, `company.*`

    * **serverRoute**: 단일 등록 범위 HTTP 라우트를 노출합니다. `serverRouteTriggerSettings`로 선언된 **resolver** 함수는 소유자 워크스페이스에서 실행되며, 동기식 `Response`를 반환하거나, 대상 워크스페이스와 큐에 넣을 대상 로직 함수를 반환합니다. 큐에 넣는 경로의 경우 플랫폼은 `202`로 응답을 확인(ack)하고 워커 큐에서 해당 **target**을 실행합니다. [서버 라우트 트리거](#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>

    #### 라우트 트리거 페이로드

    라우트 트리거가 로직 함수를 호출하면, 함수는 [AWS HTTP API v2 형식](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)을 따르는 `RoutePayload` 객체를 받습니다.
    `twenty-sdk/logic-function`에서 `RoutePayload` 타입을 임포트하세요:

    ```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`                  | JSON 파싱 이전의 원본 UTF-8 요청 본문입니다. HMAC 스타일 웹훅 서명을 검증하는 데 유용합니다(예: GitHub의 `X-Hub-Signature-256`, 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`). 상태 코드와 응답 헤더를 제어하려면 `twenty-sdk/logic-function`에서 `Response`를 반환하세요:

    ```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`, `Access-Control-Allow-Origin`과 같은 CORS 헤더, 또는 사용자 정의 `X-*` 헤더)는 응답이 전송되기 전에 조용히 제거됩니다. 허용되는 응답 헤더는 다음과 같습니다:

    * `content-type`
    * `content-language`
    * `content-disposition`
    * `cache-control`
    * `retry-after`

    <Note>
      상태 코드는 유효한 HTTP 상태 코드(100에서 599 사이)여야 합니다. 응답 헤더 이름은 대소문자를 구분하지 않습니다.
    </Note>

    #### 플랫폼 오류 응답

    핸들러 자체의 응답 외에도, 플랫폼은 일부 상황에서 라우트 호출에 직접 응답합니다. 라우트나 함수가 존재하지 않을 때는 `404`, 애플리케이션이 중지된 상태일 때는 `403`, 실행 속도 제한에 도달했을 때는 `429`, 애플리케이션의 프로덕션 `dependencies`가 너무 커서 설치할 수 없을 때는 `422`를 반환합니다. 자세한 내용은 [dependencies size limits](/l/ko/developers/extend/apps/getting-started/project-structure#dependencies)를 참조하세요.

    #### 서버 라우트 트리거

    `httpRouteTriggerSettings`는 `/s/` 아래에 함수를 노출하고 요청 호스트에서 워크스페이스를 해석합니다. 이는 각 워크스페이스가 자체 도메인을 가질 때 동작합니다. 그러나 타사 공급자는 모든 테넌트의 이벤트를 **하나의** URL로 전달합니다. 그 경우에는 `serverRouteTriggerSettings`를 사용하세요.

    트리거는 두 부분으로 구성됩니다:

    1. **resolver** 로직 함수 — `serverRouteTriggerSettings`로 선언되는 — 는 **소유자 워크스페이스**(애플리케이션 등록을 소유한 워크스페이스)에서 실행됩니다. 이 함수는 들어오는 요청을 검사한 뒤 다음 중 하나를 반환합니다:

       * `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — 플랫폼은 지정된 워크스페이스에서 해당 대상 함수를 큐에 넣고 `202 { queued: true }`로 응답을 확인(ack)합니다, 또는
       * `twenty-sdk/logic-function`의 `Response` — 플랫폼은 해당 HTTP 응답을 **동기적**으로 그대로 반환하고, target을 큐에 넣지는 **않습니다**(Slack `url_verification`과 같은 챌린지 핸드셰이크에 사용하십시오).

       resolver는 단일 인가 지점입니다 — URL에는 resolver의 식별자만 포함됩니다. **요청 서명을 검증하기에 가장 적합한 위치입니다**. resolver는 어떤 부수 효과가 발생하기 전에 실행되며, 원본 `rawBody`와 전달된 헤더에 접근할 수 있고, 대상에 전혀 접근하지 않고도 요청을 거부할 수 있습니다.
    2. 그 다음 **target** 로직 함수 — 일반적인 워크스페이스별 로직 함수 — 가 resolver가 반환한 payload(또는 resolver가 변환하지 않았다면 원본 요청 payload)를 가지고 결정된 워크스페이스에서 실행됩니다. resolver가 enqueue 경로를 선택한 경우, 해당 반환 값은 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
    ```

    이 식별자는 manifest에 있는 resolver의 `universalIdentifier`입니다. 해당 URL을 공급자에 등록하세요.

    **검증 `GET`에 응답하기.** 일부 공급자는 이벤트를 나중에 `POST`할 동일한 URL로 챌린지가 포함된 `GET`을 전송하여 엔드포인트에 전달하기 전에 이를 검증합니다. Meta의 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>

    **Resolver 계약.** SDK의 `LogicFunctionConfig` 타입은 컴파일 타임에 이를 강제합니다. `serverRouteTriggerSettings`를 설정하는 즉시, 핸들러는 `Response` 또는 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(또는 이 둘 중 하나를 반환하는 `Promise`)을 반환하도록 제한됩니다. dispatch 경로에서 `workspaceId`는 대상 함수가 설치된 워크스페이스여야 하며, 그렇지 않으면 요청은 `404`로 거부됩니다. 두 가지 형태 중 어느 것에도 일치하지 않는 결과 — 식별자가 UUID가 아닌 경우를 포함해 —는 `502`로 거부됩니다.

    | 필드                                       | 유형               | 노트                                               |
    | ---------------------------------------- | ---------------- | ------------------------------------------------ |
    | `workspaceId`                            | `string`         | 대상 함수가 실행될 워크스페이스의 UUID입니다.                      |
    | `targetLogicFunctionUniversalIdentifier` | `string`         | 해당 워크스페이스에서 호출할 로직 함수의 `universalIdentifier`입니다. |
    | `payload`                                | `object` (선택 사항) | 설정된 경우, 대상 함수에 전송되는 요청 본문을 대체합니다.                |

    <Warning>
      **서명 검증은 사용자 책임입니다 — resolver에서 검증하세요.** 플랫폼은 요청 서명을 검증하지 않습니다. resolver는 이를 수행하기에 권장되는 위치입니다. 가장 먼저 실행되며, `event.rawBody`와 `forwardedRequestHeaders`에 나열한 헤더에 접근할 수 있고, 오류를 발생시키거나 일치하지 않는 `workspaceId`를 반환하면 대상이 호출되기 전에 디스패치를 중단합니다. 검증을 대신 대상 쪽으로 미루는 경우, 대상은 `rawBody`와 헤더를 잃지 않도록 주의해야 합니다. 즉, resolver가 `payload`를 반환해서는 안 됩니다. 항상 어떤 부수 효과가 발생하기 **이전**에 검증을 수행하고, 상수 시간 비교를 사용하세요.
    </Warning>

    요청 서명의 경우 대부분의 공급자는 HMAC-SHA256으로 서명합니다. 서로 다른 부분은 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열입니다. 몇 가지 예시는 다음과 같습니다:

    | 공급자                          | 포워딩할 헤더                                                | 서명 문자열                       | 다이제스트                              |
    | ---------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
    | Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (`whsec_` 제거 후 시크릿이 base64) |
    | Stripe                       | `stripe-signature`                                     | `{timestamp}.{rawBody}`      | hex                                |
    | GitHub                       | `x-hub-signature-256`                                  | `{rawBody}`                  | hex (`sha256=` prefix 포함)          |
    | Shopify                      | `x-shopify-hmac-sha256`                                | `{rawBody}`                  | base64                             |
    | Slack                        | `x-slack-signature`, `x-slack-request-timestamp`       | `v0:{timestamp}:{rawBody}`   | hex (`v0=` prefix 포함)              |

    위의 resolver 예제는 이미 GitHub HMAC-SHA256 플로우를 보여 줍니다. 통합하려는 공급자에 따라 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열을 조정하세요.

    <Note>
      resolver가 dispatch 객체를 반환하면, 라우트는 `202 { queued: true }`로 응답하고 대상 함수는 워커 큐에서 실행됩니다. 호출자는 대상 함수의 지연 시간, 결과, 실패를 전혀 관찰하지 못하며(이러한 정보는 실행 로그에 기록됩니다). 이렇게 하면 발신자 재전송으로 인해 처리 지연이 더 커지는 것을 막을 수 있으며, 이는 웹훅 수신에서 바람직한 동작입니다.

      호출자가 동일한 요청에서 응답 본문을 반드시 읽어야 하는 경우(챌린지 핸드셰이크, 대화형 확인 등)에는, 대신 **resolver**에서 `Response`를 반환하십시오. 플랫폼은 이를 동기적으로 그대로 반환하고 큐는 건너뜁니다. 이때 헤더는 HTTP 라우트 응답과 동일한 allow-list를 통과합니다. resolver는 빠르게 유지하세요. 일부 공급자(예: Slack)는 몇 초 안에 타임아웃됩니다. resolver는 public endpoint로 접근 가능하므로, 엣지에서 rate limiting으로 보호하세요.
    </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`                                       | 변경된 레코드의 ID입니다.                                                                |
    | `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`                                                                                      |

    소프트 삭제의 경우, 레코드의 `deletedAt` 필드가 변경되므로 `.deleted`는 업데이트 스타일 구조를 따릅니다.
    영구 삭제의 경우 `.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,
      };
    };
    ```

    이메일 업데이트에만 트리거되도록:

    ```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/ko/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/ko/developers/extend/apps/logic/key-value-store), [연결](/l/ko/developers/extend/apps/logic/connections), `runAgent`, `getPublicAssetUrl`, 크레딧 청구가 이에 해당합니다.

    #### 함수를 AI 도구 또는 워크플로 작업으로 노출하기

    로직 함수는 두 가지 영역에서 노출될 수 있으며, 각 영역마다 자체 트리거가 있습니다:

    * **`toolTriggerSettings`** — 이 설정을 사용하면 함수가 Twenty의 AI 기능(채팅, MCP, 함수 호출)에서 검색 가능해집니다. 표준 JSON 스키마(LLM이 기본적으로 이해하는 형식)를 사용합니다.
    * **`workflowActionTriggerSettings`** — 시각적 워크플로 빌더에서 함수를 단계로 나타나게 합니다. 빌더가 적절한 필드 에디터, 변수 선택기, 레이블을 렌더링할 수 있도록 Twenty의 풍부한 `InputSchema`를 사용합니다.

    함수는 둘 중 하나만 또는 둘 다 선택할 수 있습니다. 이들 설정은 `cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`와 나란히 위치하며 — 패턴도 형태도 동일합니다.

    <Note>
      **워크플로 Code 액션과의 관계.** 워크플로 빌더에 내장된 **Code** 액션 자체가 하나의 로직 함수입니다. Twenty는 Code 단계마다 하나씩 생성하고, 해당 편집기를 인라인으로 노출합니다. `workflowActionTriggerSettings`는 그러한 일회성 인라인 코드를 **재사용 가능한** 액션으로 전환하는 방법입니다. 앱에서 함수를 한 번만 정의하면, 각 Code 단계에 코드를 복사·붙여넣기 하는 대신 모든 워크플로에서 선택할 수 있게 됩니다. 엔드유저 관점에 대해서는 사용자 가이드의 [Code 액션](/l/ko/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`는 모두 선택 사항입니다. 생략되면 매니페스트 빌더가 핸들러 소스 코드에서 이를 추론합니다(AI 도구의 경우 JSON 스키마, 워크플로 작업의 경우 Twenty의 `InputSchema`). 더 풍부한 타입 지정을 원한다면 명시적으로 제공하세요 — 예를 들어 워크플로 빌더를 위해 `CURRENCY` 또는 `RELATION`처럼 `FieldMetadataType`을 인지하는 필드를 사용하거나, AI 에이전트가 읽을 수 있는 `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`)를 정의한 후 `twenty-sdk/logic-function`의 `jsonSchemaToInputSchema`를 사용해 워크플로 작업용으로 변환하세요. `toolTriggerSettings.inputSchema`는 JSON Schema를 그대로 사용하지만, `workflowActionTriggerSettings.inputSchema`는 Twenty의 `InputSchema`를 요구합니다:

    ```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`  | 빌더에서 구성 가능한 필드(변수 선택기 포함)로 렌더링되는 Twenty의 강력한 `InputSchema`입니다. 선택 사항입니다. 생략하면 핸들러에서 자동으로 유추됩니다.     |
    | `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`을 작성하세요.** AI 에이전트는 도구를 언제 사용할지 결정하기 위해 함수의 `description` 필드에 의존합니다. 도구가 무엇을 하는지와 언제 호출해야 하는지 구체적으로 작성하세요.
    </Note>
  </Accordion>
</AccordionGroup>

<Note>
  **런타임 헬퍼.** `twenty-sdk/utils`는 작은 런타임 헬퍼를 다시 export하여 핸들러가 `twenty-shared`를 직접 import하지 않도록 합니다. 예를 들어 `isDefined(value)`는 `null`과 `undefined` 모두에 대해 `false`를 반환합니다. 이를 사용하면 선택적 핸들러 입력을 안전하게 좁힐 수 있습니다. 이러한 입력은 타입이 `T | undefined`로 지정되어 있더라도 런타임에는 `null`로 전달될 수 있습니다:

  ```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` 함수로 선언되며 트리거 설정을 받지 않습니다. `definePreInstallLogicFunction`, `definePostInstallLogicFunction`, 및 `defineUninstallLogicFunction` 에 대해서는 [설치 훅](/l/ko/developers/extend/apps/config/install-hooks)을 참고하세요.
</Note>

## 타임라인 활동 만들기

`createTimelineActivity()`를 사용하여 로직 함수에서 명시적 도메인 이벤트를 게시합니다. 먼저 이벤트를 [타임라인 활동 유형](/l/ko/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는 범용 식별자를 설치별 메타데이터 ID로 확인하고, 타임라인 활동 유형이 호출 앱에 속하는지 검증하며, 새 활동에 표시 메타데이터의 스냅샷을 저장합니다.

필수 입력값은 `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` 패키지는 로직 함수와 프런트 컴포넌트에서 Twenty API와 상호작용하기 위한 타입이 지정된 두 가지 GraphQL 클라이언트를 제공합니다.

| 클라이언트               | 가져오기                         | 엔드포인트                            | 생성 여부?         |
| ------------------- | ---------------------------- | -------------------------------- | -------------- |
| `CoreApiClient`     | `twenty-client-sdk/core`     | `/graphql` — 워크스페이스 데이터(레코드, 객체) | 예, 개발/빌드 시점    |
| `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,
      },
    });
    ```

    이 클라이언트는 선택 집합 구문을 사용합니다: 필드를 포함하려면 `true`를 전달하고, 인수에는 `__args`를 사용하며, 관계는 객체를 중첩합니다. 워크스페이스 스키마를 기반으로 완전한 자동 완성과 타입 검사를 제공합니다.

    <Note>
      **CoreApiClient는 개발/빌드 시점에 생성됩니다.** 먼저 `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` 엔드포인트를 조회합니다. 이는 `CoreApiClient`와 동일한 `runAs` 옵션을 사용합니다. [호출에서 사용하는 액세스 주체](#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`(워크스페이스별 ID가 아님)를 사용하므로, 앱이 설치된 모든 워크스페이스에서 업로드 코드가 동작합니다.
    * 반환된 `url`은 업로드된 파일에 액세스하는 데 사용할 수 있는 서명된 URL입니다.
  </Accordion>
</AccordionGroup>

<Note>
  코드가 Twenty에서 실행될 때(로직 함수 또는 프런트 컴포넌트), 플랫폼이 자격 증명을 환경 변수로 주입합니다:

  * `TWENTY_API_URL` — Twenty API의 기본 URL
  * `TWENTY_APP_ACCESS_TOKEN` — 기본 액세스를 위한 단기 키입니다. 실행 뒤에 사람이 있는 경우 해당 사람의 역할과 애플리케이션의 역할이 교차된 역할이며, 아무도 없는 경우 애플리케이션 자체의 역할입니다. 로직 함수에서는 실행을 트리거한 사람이, 프런트 컴포넌트에서는 페이지를 보고 있는 사람이 해당 사람입니다.
  * `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — 애플리케이션 자체의 역할로만 범위가 제한된 단기 키입니다. 로직 함수에서만 사용되며, այնտեղ 항상 주입되고, `runAs: 'application'`이 사용하는 키입니다.

  이 키들을 클라이언트에 전달할 필요는 **없습니다**. 클라이언트가 `process.env`에서 자동으로 읽으며, [호출에서 사용하는 액세스 주체](#whose-access-a-call-uses)에서 둘 중 무엇을 선택하는지 다룹니다. 애플리케이션 자체의 권한은 `defineApplicationRole()`로 선언된 역할(또는 `application-config.ts`의 `defaultRoleUniversalIdentifier`를 통해 참조된 역할)로 결정됩니다. 사람으로 동작하는 실행은 해당 역할이나 그 사람의 역할을 절대 초과할 수 없습니다.
</Note>
