snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
---
title: 백그라운드 작업
description: 긴 시간이 걸리거나 속도 제한이 걸린 작업은 모두 인라인으로 처리하지 말고, 또 다른 로직 함수 실행을 큐에 넣어 Twenty 워커에 위임하세요.
icon: layer-group
---
로직 함수 실행은 `timeoutSeconds`(최대 900초)로 제한됩니다. 해당 시간 안에 끝나지 않는 모든 작업(전체 재동기화, 레코드별 팬아웃, 속도 제한이 걸린 서드파티 API 등)은 더 작은 실행 단위로 나눠야 합니다.
`enqueueJobs`는 바로 그 일을 합니다. Twenty 워커에게 나중에 앱의 로직 함수 중 하나를 실행해 달라고 요청하며, 각 페이로드당 한 번씩 실행되고 각 실행은 자체 프로세스에서 자체 타임아웃 한도로 처리됩니다. 호출은 즉시 반환됩니다.
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## 실행 대기열에 추가하기
`twenty-sdk/logic-function`에서 `enqueueJobs`를 가져와 실행할 로직 함수의 `universalIdentifier`를 지정하고, 대기열에 추가할 각 실행마다 하나의 페이로드를 전달하세요.
```ts src/logic-functions/sync-all-contacts.ts
import { enqueueJobs } from 'twenty-sdk/logic-function';
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
});
```
각 실행은 다른 모든 트리거와 정확히 동일하게 핸들러 인수로 해당 페이로드를 받습니다. 대상은 호출자와 **동일한 애플리케이션**에 속해야 합니다. 다른 앱의 함수를 대기열에 추가하려고 하면 `Logic function not found` 오류와 함께 거부되며 아무것도 대기열에 추가되지 않습니다. 한 번의 호출로 최대 `200`개의 페이로드를 허용합니다.
<Note>
`enqueueJobs`는 작업이 실행되었을 때가 아니라 작업이 수락되는 즉시 반환됩니다. 대상들의 결과를 반환하지는 않습니다. 결과를 다시 읽어와야 한다면, 각 대상이 생성한 내용을 [키-값 스토어](/l/ko/developers/extend/apps/logic/key-value-store) 또는 워크스페이스 레코드에 기록하도록 하세요.
</Note>
<Note>
호출당 하나의 작업을 대기열에 추가하는 이전 `enqueueJob` 도우미는 더 이상 사용되지 않습니다. 대신 요소가 하나인 `payloads` 목록과 함께 `enqueueJobs`를 사용하세요.
</Note>
## 작업 옵션
옵션은 배치의 모든 실행에 적용됩니다.
| 옵션 | 기본값 | 범위 | 하는 일 |
| ------------ | --- | ------------------------ | ------------------------------------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | 전체 추가 큐 시도 횟수. 애플리케이션에서 요청한 재시도는 `3`회로 제한됩니다. 두 번 실행해도 안전한 핸들러에 대해서만 이 값을 올리세요. |
| `delayMs` | `0` | `0`–`604800000` (7 days) | 실행이 가능해지기 전까지 이만큼 대기합니다. |
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**우선순위는 아직 설정할 수 없습니다.** 대기열에 추가된 작업은 항상 가장 낮은 우선순위로 실행되므로, 플랫폼 작업이 애플리케이션 작업보다 뒤로 밀려 지연되는 일은 없습니다. 우선순위를 제어하는 기능은 곧 제공될 예정입니다.
</Note>
대기열에 추가된 실행은 이를 대기열에 추가한 함수의 실행 사용자를 상속하므로, 동일한 권한으로 동작합니다.
## 일시적 실패 재시도하기
Twenty는 애플리케이션 코드의 모든 예외를 재시도하지 않습니다. 일반 오류가 throw되면 영구적 실패로 처리됩니다. 일시적 실패의 경우, 큐에 대기 중인 로직 함수는 `RetryableLogicFunctionError`를 throw하여 최대 세 번의 재시도를 요청할 수 있습니다.
```ts
import {
type LogicFunctionExecutionContext,
RetryableLogicFunctionError,
} from 'twenty-sdk/logic-function';
export const handler = async (
_payload: unknown,
{ retryCount, maxRetries }: LogicFunctionExecutionContext,
) => {
const response = await fetch('https://api.example.com/contacts');
if (response.status === 429 || response.status >= 500) {
throw new RetryableLogicFunctionError(
`The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
);
}
};
```
가능한 경우 `RetryableLogicFunctionError`를 직접 throw하세요. 이를 확장하는 경우 `name`을 바꾸지 마세요. Twenty는 실행 런타임 전반에서 직렬화된 이름 `RetryableLogicFunctionError`를 인식합니다.
`retryCount`는 최초 실행 시 `0`이며, 애플리케이션 코드가 재시도를 요청할 때만 증가합니다. `maxRetries`는 최대 `3`이며, 큐에 대기 중인 작업의 전체 재시도 한도가 더 작으면 더 낮을 수 있습니다. 플랫폼 실패는 큐의 전체 안전 예산을 계속 소모하지만 `retryCount`를 증가시키지는 않습니다.
큐는 지수 백오프와 지터를 사용하여 재시도 시도를 지연합니다. 정확한 지연 시간은 의도적으로 보장되지 않으므로, 애플리케이션 코드는 정확한 시간에 재시도가 발생하는 것에 의존해서는 안 됩니다. `maxRetries`에 도달하면, 또 다른 `RetryableLogicFunctionError`는 추가 실행 없이 최종 애플리케이션 실패로 기록됩니다.
<Warning>
재시도는 전체 핸들러를 다시 실행하며, 일부 부수 효과가 성공한 후 발생할 수 있습니다. 재시도를 요청하기 전에 핸들러를 멱등적으로 만드세요.
</Warning>
## 사용 예: 긴 동기화를 페이지 단위로 처리하기
전형적인 형태는, 다음 커서를 사용해 *자기 자신*을 다시 대기열에 추가하는 함수입니다. 각 실행은 자신의 타임아웃 안에서 한 페이지 분량의 작업만 처리하고, 더 이상 처리할 것이 없으면 이 체인은 멈춥니다.
```ts src/logic-functions/sync-contacts-page.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs } from 'twenty-sdk/logic-function';
const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';
const handler = async (params: { cursor?: string }) => {
const { contacts, nextCursor } = await fetchContactsPage(params.cursor);
await importContacts(contacts);
if (nextCursor) {
await enqueueJobs({
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
payloads: [{ cursor: nextCursor }],
delayMs: 2_000,
});
}
return { imported: contacts.length, done: !nextCursor };
};
export default defineLogicFunction({
universalIdentifier: SYNC_CONTACTS_PAGE,
name: 'sync-contacts-page',
timeoutSeconds: 120,
handler,
});
```
## 레코드별로 팬아웃하기
작업이 본질적으로 항목 단위일 때는, 단일 호출에서 항목마다 하나의 작업을 대기열에 추가하고 워커들이 인라인 루프를 도는 대신 병렬로 처리하도록 하세요.
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payloads: companies.map((company) => ({ companyId: company.id })),
retryLimit: 2,
});
```
## 장시간 실행 작업을 위한 모범 사례
대부분의 장시간 작업은 두 가지 규칙으로 다룰 수 있습니다: **루프 대신 재귀를 사용**하고, **실행마다 한정된 크기의 청크를 처리**하세요.
한 번에 모든 것을 처리하려는 실행은 실패 패턴입니다. 타임아웃에 걸리고, 재시도가 발생하면 전체 작업을 다시 처음부터 시작합니다. 대신, 하나의 청크가 `timeoutSeconds` 안에 여유 있게 끝나도록 크기를 잡고, 현재 위치를 저장한 뒤 다음 실행을 대기열에 추가하세요.
```ts src/logic-functions/enrich-companies-batch.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs, kv } from 'twenty-sdk/logic-function';
const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
const CHUNK_SIZE = 50;
const handler = async (params: { offset?: number }) => {
const offset = params.offset ?? 0;
const companies = await listCompaniesToEnrich({
offset,
limit: CHUNK_SIZE,
});
for (const company of companies) {
await enrichCompany(company);
}
await kv.set('enrich:progress', { offset: offset + companies.length });
if (companies.length === CHUNK_SIZE) {
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
payloads: [{ offset: offset + CHUNK_SIZE }],
});
}
return { processed: companies.length, done: companies.length < CHUNK_SIZE };
};
export default defineLogicFunction({
universalIdentifier: ENRICH_COMPANIES_BATCH,
name: 'enrich-companies-batch',
timeoutSeconds: 300,
handler,
});
```
이 접근이 유효한 이유:
* **청크 크기는 평균이 아니라 가장 느린 항목을 기준으로 정하세요.** `CHUNK_SIZE × 최악의 항목 처리 시간`은 여유를 두고 `timeoutSeconds` 안에 들어와야 합니다. 그렇지 않으면 실행이 중단될 때 청크의 끝부분이 유실됩니다.
* **종료 조건을 명시적으로 만드세요.** 가득 찬 청크가 반환되었을 때만 재귀적으로 호출하세요. "결과 없음"만을 기준으로 멈추는 체인은, 소스가 중간에 짧은 페이지를 한 번이라도 반환하면 영원히 계속될 수 있습니다.
* **다음 실행을 대기열에 추가하기 전에 진행 상태를 저장**해서, 실패한 링크가 처음부터가 아니라 마지막으로 완료된 청크부터 다시 시작하도록 하세요.
* **각 청크는 멱등성을 유지하세요.** 재시도 후에 하나의 청크를 다시 처리하더라도 중복 기록이 발생하지 않도록, 처리 중인 레코드나 외부 ID를 기준으로 쓰기를 수행해야 합니다.
* **거대한 한 번의 팬아웃보다 청크 단위 체인을 우선적으로 사용**하세요. 작업이 호출 한도(rate limit)가 있는 서드파티에 도달하는 경우, `delayMs`가 있는 체인은 스스로 속도를 조절하지만, 수천 개의 작업을 한 번에 대기열에 추가하면 모두 즉시 실행 가능 상태가 됩니다.

View File

@@ -0,0 +1,269 @@
---
title: 연결
description: 앱이 OAuth를 통해 타사 서비스에서 사용자를 대신해 동작하도록 하세요.
icon: plug
---
연결은 사용자가 외부 서비스(Linear, GitHub, Slack 등)에 대한 자격 증명을 보유하는 것을 말합니다. 앱은 해당 자격 증명을 **어떻게** 얻는지(즉, **연결 제공자**)를 선언하고, 런타임에 이를 사용해 서드파티 API에 인증된 호출을 수행합니다.
현재는 OAuth 2.0만 지원됩니다. 향후 자격 증명 유형(개인 액세스 토큰, API 키, 기본 인증)은 동일한 인터페이스에 연동되며 — 이미 `defineConnectionProvider({ type: 'oauth', ... })`를 사용하는 앱은 마이그레이션이 필요하지 않습니다.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="앱의 연결을 얻는 방법을 선언합니다">
연결 제공자는 앱에 필요한 OAuth 핸드셰이크를 설명합니다. 사용자가 앱 설정에서 "연결 추가"를 클릭하고 제공자의 동의 화면을 완료하면, 워크스페이스에 `ConnectedAccount` 행이 생성됩니다.
정상 동작하려면 **두 개의 파일**이 필요합니다 — 연결 제공자, 그리고 OAuth 클라이언트 자격 증명을 보유하는 `defineApplication`의 해당 `serverVariables` 선언.
```ts src/connection-providers/linear-connection.ts
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});
```
```ts src/application.config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});
```
핵심 요점:
* `name`은 `listConnections({ providerName })`에서 사용하는 고유 식별자 문자열입니다(kebab-case, `^[a-z][a-z0-9-]*$`와 일치해야 함).
* `displayName`은 앱별 설정 탭과 AI 도구 목록에 표시됩니다.
* `clientIdVariable` / `clientSecretVariable`은 값이 아닌 **이름**이며, `defineApplication.serverVariables`에 선언된 키와 일치해야 합니다. 실제 `client_id`와 `client_secret`은 서버 관리자가 앱 등록 UI를 통해 입력하며, 저장소에 커밋되지 않습니다.
* `serverVariables`(`applicationVariables` 아님)을 사용하세요 — OAuth 자격 증명은 서버 전체에 적용되며 Twenty 서버마다 하나의 OAuth 앱만 사용합니다.
* 두 `serverVariables`가 모두 채워질 때까지, 앱별 설정 탭에는 "서버 관리자 필요" 힌트가 표시되고 "연결 추가" 버튼이 비활성화됩니다.
* `type: 'oauth'`는 현재 지원되는 유일한 값입니다. 구분자는 전방 호환됩니다: 향후 유형(`'pat'`, `'api-key'`, ...) `oauth`와 함께 새로운 하위 구성 블록이 추가됩니다.
제공자가 허용 목록에 추가해야 할 OAuth 콜백 URL은 다음과 같습니다:
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="연결 시 로직 함수 실행하기" description="연결이 성립되는 즉시 반응하기">
일부 공급자는 연결 시점에, 연결을 실제로 사용할 수 있게 되기 전에 유지해야 하는 데이터를 제공하기도 합니다. 대표적인 예로 Slack의 경우 OAuth 응답에, 수신 이벤트를 키로 사용할 워크스페이스의 `team_id`가 포함됩니다. `onConnectLogicFunction`을 동일한 앱의 로직 함수(그 로직 함수의 `universalIdentifier`로 식별)에 연결하면, `ConnectedAccount`가 생성된 직후 해당 로직 함수가 실행됩니다.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});
```
이 훅은 **연결 중인 워크스페이스에서 비동기적으로 실행**됩니다(대기하지 않고 큐에 넣어 처리하므로), 훅이 느리게 동작하거나 실패하더라도 OAuth 콜백을 절대 차단하거나 중단하지 않습니다. 따라서 멱등적으로 구현하고, 재시도를 스스로 처리하도록 하십시오. 핸들러는 다음을 수신합니다:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
이후에는 `getConnection(connectedAccountId)`를 사용하여 최신 액세스 토큰을 읽고, 공급자의 API(예: Slack `auth.test`)를 호출하거나 [key-value store](/l/ko/developers/extend/apps/logic/key-value-store)에 매핑을 저장합니다.
</Accordion>
<Accordion title="연결 해제 시 로직 함수 실행하기" description="연결이 제거될 때 정리 작업 수행하기">
앱이 연결 시점에 확보(클레임)한 모든 항목은 연결이 해제될 때 반드시 해제되어야 합니다. 예를 들어, 연결 시점에 `team_id`를 확보(클레임)하는 Slack 연동은, 동일한 Slack 팀에 다른 워크스페이스가 연결될 수 있도록 그 클레임을 해제해야 합니다. `onDisconnectLogicFunction`을 동일한 앱의 로직 함수를 참조하도록 설정하면, `ConnectedAccount`가 삭제된 직후 해당 로직 함수가 실행됩니다.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});
```
on-connect 훅과 마찬가지로 **연결이 끊기는 워크스페이스에서 비동기적으로 실행**되며, 연결 해제를 차단(block)하지 않습니다. 핸들러는 동일한 형태의 페이로드를 전달받습니다:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
훅이 실행될 때는 `ConnectedAccount`가 이미 제거된 상태이므로 `getConnection(connectedAccountId)`는 더 이상 해결(resolve)되지 않습니다. 정리(cleanup)에 필요한 모든 것(예: `team_id`, 외부 구독 ID)은 연결 시점에 `connectedAccountId`를 키로 사용해 [key-value store](/l/ko/developers/extend/apps/logic/key-value-store)에 미리 기록되어 있어야 합니다.
이 훅은 연결이 자체적으로 제거될 때 실행됩니다. 앱을 제거(uninstall)하면, 데이터베이스의 CASCADE를 통해 연결이 삭제되므로 해당 경우에는 이 훅이 실행되지 않습니다. 그 경로에 대해 `defineApplication`에 `uninstallLogicFunction`을 선언하세요. 이 함수는 앱의 메타데이터가 삭제되기 전에 실행되므로, 아직 `listConnections`를 호출해 남은 항목을 정리(clean up)할 수 있습니다.
</Accordion>
<Accordion title="listConnections / getConnection" description="로직 함수에서 연결 사용">
로직 함수 핸들러 내부에서 `listConnections({ providerName })`는 지정된 제공자에 대한 이 앱의 `ConnectedAccount` 행을 갱신된 액세스 토큰과 함께 반환합니다.
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};
```
각 연결에는 다음이 포함됩니다:
| 필드 | 설명 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `id` | 고유한 행 ID; 단일 항목을 다시 가져오려면 `getConnection(id)`에 전달하세요. |
| `visibility` | `'user'`(한 워크스페이스 구성원에게만 비공개) 또는 `'workspace'`(모든 구성원과 공유) |
| `scopes` | 상위 제공자가 부여한 OAuth 권한(`visibility`와는 구별됨 — 서로 관련 없음) |
| `userWorkspaceId` | 소유자의 userWorkspace ID — HTTP 경로 트리거에서 "요청 사용자의 연결"을 선택할 때 유용합니다 |
| `accessToken` | 최신 OAuth 액세스 토큰(만료 시 자동으로 갱신됨) |
| `name` | 연결의 표시 이름(OAuth 콜백 시 자동으로 결정되며, 사용자가 이름을 변경할 수 있음) |
| `핸들` | 연결된 업스트림 계정의 이메일로, 매번 재연결할 때마다 갱신되며 토큰 교환 시 반환되는 OIDC `id_token`에서 결정됩니다. 공급자가 이메일을 반환하지 않으면 연결 중인 Twenty 사용자의 이메일로 대체됩니다 |
| `authFailedAt` | 가장 최근 갱신이 실패했을 때 설정됩니다; 사용자가 다시 연결해야 합니다 |
핵심 요점:
* 제공자별로 필터링하려면 `{ providerName }`를 전달하세요; 생략하면 이 앱이 모든 제공자에서 보유한 모든 연결을 가져옵니다.
* 서버는 반환하기 전에 액세스 토큰을 투명하게 갱신합니다. 핸들러는 항상 사용 가능한 토큰(또는 `authFailedAt`이 설정된 상태)을 보게 됩니다.
* `getConnection(id)`는 단일 행 버전입니다.
</Accordion>
<Accordion title="사용자별 대 워크스페이스 공유 가시성" description="사용자가 비공개 자격 증명과 공유 자격 증명 사이를 선택하는 방법">
사용자가 "연결 추가"를 클릭하면, 가시성을 선택하라는 프롬프트가 표시됩니다:
* **나만 사용** — 해당 자격 증명은 연결한 사용자에게만 비공개입니다. 그 사용자를 대신해 호출되는 모든 로직 함수(`isAuthRequired: true`가 설정된 HTTP 경로 트리거)는 이를 볼 수 있습니다; cron 트리거와 데이터베이스 이벤트는 볼 수 없습니다.
* **워크스페이스 공유** — 모든 워크스페이스 구성원이 해당 자격 증명을 사용할 수 있습니다. 요청 사용자가 없으므로 Cron/데이터베이스 트리거도 이를 볼 수 있습니다.
각 핸들러에 맞는 유형을 사용하세요:
```ts
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');
```
각 (사용자, 제공자)당 여러 연결이 허용되므로, 동일한 사용자가 "Personal Linear"와 "Work Linear"를 나란히 보유할 수 있습니다.
</Accordion>
<Accordion title="일회성 제공자 설정" description="서드파티 서비스에 OAuth 앱을 등록하세요">
각 연결 제공자마다 서버 관리자가 먼저 서드파티에 OAuth 앱을 등록해야 합니다.
1. 제공자의 개발자 설정으로 이동하세요(예: https://linear.app/settings/api/applications/new).
2. **Redirect URI**를 `\<SERVER_URL>/auth/apps/callback`으로 설정하세요.
3. 생성된 **Client ID**와 **Client Secret**을 복사하세요.
4. 서버 관리자 권한으로 Twenty에서 설치된 앱을 열고 → 해당 `serverVariables`에 값을 설정하세요.
5. 그런 다음 워크스페이스 구성원은 앱별 **연결** 섹션에서 연결을 추가할 수 있습니다.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: 키-값 저장소
description: 기본 제공 애플리케이션 키-값 스토어를 사용해 중간 결과를 유지하고, 데이터를 캐시하며, 논리 함수 실행 간 상태를 공유하세요.
icon: database
---
로직 함수는 단기간 실행되는 샌드박스된 Node.js 프로세스에서 동작하며, 한 번 실행이 끝나면 메모리에 남아 있는 것은 아무것도 유지되지 않습니다. 실행 사이에서 **어떤 값을 기억해야 할 때**(비용이 큰 API 응답을 캐시하거나, 증분 동기화를 위한 커서를 저장하거나, 작업을 디바운스하거나, 한 함수에서 다른 함수로 상태를 넘길 때)는 기본 제공 키-값 스토어에 상태를 저장하세요.
각 애플리케이션은 자체적으로 격리된 네임스페이스를 갖습니다. 항목은 인증된 앱을 기준으로 키가 지정되므로, 키가 다른 애플리케이션과 충돌하거나 다른 애플리케이션에 의해 읽히는 일은 없습니다.
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## 가져오기, 설정, 삭제
`twenty-sdk/logic-function`에서 `kv`를 임포트하세요. 값은 JSON으로 직렬화할 수 있는 모든 페이로드가 될 수 있습니다.
```ts src/logic-functions/sync-linear-issues.ts
import { kv } from 'twenty-sdk/logic-function';
// Read a value. Returns null when the key is missing.
const cursor = await kv.get<string>('sync-cursor:linear');
// Write a value. Creates the entry on first write, updates it afterwards.
await kv.set('sync-cursor:linear', newCursor);
// Delete an entry. Returns true when an entry was removed.
await kv.delete('sync-cursor:linear');
```
## 범위
각 항목에는 스코프가 있으며, 모든 호출에서 옵션으로 전달됩니다. 기본값은 `WORKSPACE`입니다.
* **`WORKSPACE`** (기본값) — 항목은 현재 워크스페이스에 설치된 앱 인스턴스에만 비공개입니다. 앱을 설치한 각 워크스페이스는 자체적인 독립 키 집합을 가집니다. 캐시, 커서, 워크스페이스별 상태에는 이 스코프를 사용하는 것이 적합합니다.
* **`SERVER`** — 항목은 서버에서 실행 중인 앱의 모든 설치 간에 공유됩니다. 서버 항목은 \*\*클레임(claim)\*\*처럼 동작합니다. 저장된 값은 항상 해당 키를 클레임한 workspaceId이며(`set`에서 `value`를 생략하면 현재 워크스페이스에 대해 키를 클레임합니다), 그 워크스페이스만 해당 값을 덮어쓰거나 삭제할 수 있습니다. 어떤 설치에서든 해당 항목을 읽을 수 있습니다.
서버 클레임은 워크스페이스 간 라우팅을 위해 존재합니다. [server-route resolver](/l/ko/developers/extend/apps/logic/logic-functions#server-route-trigger)는 애플리케이션 등록 소유자 워크스페이스에서 실행되지만, 인바운드 웹후크는 일반적으로 Twenty workspaceId가 아닌 외부 계정 ID만을 포함합니다. 각 워크스페이스가 연결 시점에 자신의 외부 ID를 클레임하게 한 다음, 라우트에서 이를 해결하세요:
```ts
// In the connected workspace, when the external account is linked:
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });
// In the server-route resolver (owner workspace), on each webhook:
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
scope: 'SERVER',
});
```
서버 키는 호출자의 워크스페이스에 대해서만 클레임할 수 있고 다른 워크스페이스가 덮어쓸 수 없기 때문에, 한 워크스페이스가 다른 워크스페이스에 속한 매핑을 가로채는 일은 일어날 수 없습니다. 키가 이미 다른 워크스페이스에 의해 클레임된 경우 `kv.set`은 예외를 발생시킵니다.
## 사용 예: 비용이 큰 호출 캐시하기
일반적인 사용 예는 느리거나 rate limit이 걸린 서드파티 응답을 캐시해서, 반복 실행 시 매번 비용을 지불하지 않고 재사용하도록 하는 것입니다.
```ts src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
const ONE_HOUR_MS = 60 * 60 * 1000;
type CachedRate = { rate: number; fetchedAt: number };
const handler = async (params: { from: string; to: string }) => {
const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
const cached = await kv.get<CachedRate>(cacheKey);
if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
return { rate: cached.rate, cached: true };
}
const response = await fetch(
`https://api.example.com/rate?from=${params.from}&to=${params.to}`,
);
const { rate } = (await response.json()) as { rate: number };
await kv.set(cacheKey, { rate, fetchedAt: Date.now() });
return { rate, cached: false };
};
export default defineLogicFunction({
universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
name: 'get-exchange-rate',
timeoutSeconds: 10,
handler,
});
```
## 패턴 및 팁
* **네임스페이스 구성.** 서로 다른 관심사를 분리하기 위해 키에 접두사를 붙이세요 — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
* **만료(TTL).** 이 스토어에는 내장된 만료 기능이 없습니다. 값 안에 타임스탬프를 저장해(캐시 예제처럼) 읽을 때 확인하거나, [cron-triggered function](/l/ko/developers/extend/apps/logic/logic-functions)을 통해 오래된 키를 주기적으로 정리하세요.
* **무엇을 저장할지.** 숫자, 문자열, 배열, 객체 등 JSON으로 직렬화 가능한 어떤 값이든 저장할 수 있습니다. 엔트리는 작게 유지하세요. 이 스토어는 대용량 blob이나 파일이 아니라, 조정 및 캐싱용입니다. 파일의 경우 `FILES` 필드와 [`uploadFile`](/l/ko/developers/extend/apps/logic/logic-functions#uploading-files)을 사용하세요.
* **가시성.** 항목은 워크스페이스 레코드가 아니라 인스턴스 데이터베이스에 저장됩니다. 따라서 워크스페이스 UI에 나타나지 않고, 앱의 데이터 모델의 일부도 아니며, 역할이나 객체 권한도 필요하지 않습니다.
## 대안: 조회 가능한 스토어 객체
기본 제공 스토어는 의도적으로 불투명합니다. 항목이 레코드가 아니기 때문에 UI에서 탐색하거나, 다른 객체와 관계를 맺거나, 레코드 쿼리로 필터링할 수 없습니다. 이러한 기능이 필요할 때(예: 눈에 보이는 동기화 로그나 레코드별 상태 등)에는 고유한 `key` 필드와 `RAW_JSON` `value` 필드를 가진 작은 **technical object**를 정의하고, [typed API client](/l/ko/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk)를 통해 이를 쿼리하세요. `defineObject` 레퍼런스는 [Objects](/l/ko/developers/extend/apps/data/objects)를, 키의 고유성을 강제하려면 [Data → Unique indexes](/l/ko/developers/extend/apps/data/overview#unique-indexes)를 참고하세요.
* **레코드에 스코프 지정하기.** ID를 키에 인코딩하지 말고, 스토어 객체에서 대상 객체로 [relation](/l/ko/developers/extend/apps/data/relations)을 추가하세요.
* **가시성 및 권한.** 행은 다른 레코드와 마찬가지로 워크스페이스 데이터베이스에 저장되므로, API를 통해 조회할 수 있고 앱의 [role](/l/ko/developers/extend/apps/config/roles)을 그대로 따릅니다. 스토어를 기본 UI에서 숨기고 싶다면, [navigation menu](/l/ko/developers/extend/apps/layout/navigation-menu-items)에 추가하지 마세요.
<Note>
기본 제공 스토어와 달리 커스텀 객체는 항상 하나의 워크스페이스에만 스코프가 지정되며, `SERVER` 키처럼 설치 간에 항목을 공유할 수 없습니다.
</Note>

View File

@@ -0,0 +1,908 @@
---
title: 로직 함수
description: HTTP, cron 및 데이터베이스 이벤트 트리거를 사용하여 서버 측 TypeScript 함수를 정의합니다.
icon: bolt
---
로직 함수는 Twenty 플랫폼에서 실행되는 서버 측 TypeScript 함수입니다. 이 함수들은 HTTP 요청, cron 스케줄 또는 데이터베이스 이벤트에 의해 트리거될 수 있으며 — AI 에이전트를 위한 도구로도 제공할 수 있습니다.
<AccordionGroup>
<Accordion title="defineLogicFunction" description="로직 함수와 해당 트리거 정의">
각 함수 파일은 `defineLogicFunction()`을 사용해 핸들러와 선택적 트리거가 포함된 구성을 내보냅니다.
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const body = (params.body ?? {}) as { name?: string };
const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world';
const result = await client.mutation({
createPostCard: {
__args: { data: { name } },
id: true,
name: true,
},
});
return result;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'POST',
isAuthRequired: true,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
},*/
/*cronTriggerSettings: {
pattern: '0 0 1 1 *',
},*/
});
```
사용 가능한 트리거 유형:
* **httpRoute**: 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"
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
```
```bash filename="Terminal"
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
다음으로 로그를 확인할 수 있습니다:
```bash filename="Terminal"
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
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
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'webhook-handler',
handler,
httpRouteTriggerSettings: {
path: '/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
},
});
```
핸들러에서, 전달된 헤더에 다음과 같이 접근합니다:
```ts
const handler = async (event: RoutePayload) => {
const signature = event.headers['x-webhook-signature'];
const contentType = event.headers['content-type'];
// Validate webhook signature...
return { received: true };
};
```
<Note>
헤더 이름은 소문자로 정규화됩니다. 소문자 키를 사용해 접근하세요(예: `event.headers['content-type']`).
</Note>
#### 사용자 정의 HTTP 응답
기본적으로, 핸들러에서 일반 값을 반환하면 해당 값이 `200` 응답으로 전송됩니다(객체는 JSON, 문자열은 `text/plain`). 상태 코드와 응답 헤더를 제어하려면 `twenty-sdk/logic-function`에서 `Response`를 반환하세요:
```ts
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
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
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
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
챌린지는 `event.queryStringParameters`에 도착하며, `Response`를 반환하면 동일한 요청에서 공급자에게 이를 다시 에코합니다. 문자열 본문은 `text/plain`으로 전송되며, 이는 이러한 공급자가 예상하는 형식입니다:
```ts
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
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
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
업데이트 이벤트 예시:
```ts
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
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
삭제 이벤트 예시:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### 실행 컨텍스트
모든 핸들러는 무엇이 이를 트리거했는지와 관계없이 실행 자체를 설명하는 두 번째 인수를 받습니다. 첫 번째 인수는 트리거에 따라 형태가 달라지지만, 이 인수는 달라지지 않습니다:
```ts
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
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
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
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
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
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
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
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
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
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
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
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
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
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>

View File

@@ -0,0 +1,61 @@
---
title: 개요
description: HTTP 경로, 크론 스케줄, 데이터베이스 이벤트, AI 도구 또는 워크플로우 액션에 의해 트리거되어 Twenty 내부에서 실행되는 서버 사이드 TypeScript입니다.
icon: bolt
---
Twenty 앱의 **로직 계층**은 *실행되는* 코드로, HTTP 요청, 크론 스케줄, 레코드 변경에 반응하는 서버 사이드 TypeScript 핸들러, 워크스페이스 내부에 존재하는 AI 스킬과 에이전트, 그리고 서드파티 서비스에서 사용자를 대신해 함수가 동작할 수 있도록 하는 OAuth 연결로 구성됩니다.
```text
┌─ HTTP route ──┐
│ Cron schedule │
│ Database event │ ┌────────────────────┐
triggers ─┤ AI tool call ├─────▶│ Logic function │
│ Workflow action │ │ (your handler) │
│ Manual exec │ └────────────────────┘
└────────────────────┘ │
▼
┌────────────────────────────┐
│ Twenty API (records) │
│ Third-party API │
│ (via Connection token) │
└────────────────────────────┘
```
## 이 섹션에서 다루는 내용
<CardGroup cols={2}>
<Card title="로직 함수" icon="bolt" href="/l/ko/developers/extend/apps/logic/logic-functions">
핵심 빌딩 블록 — 트리거 유형, 페이로드, 그리고 타입이 지정된 API 클라이언트입니다.
</Card>
<Card title="스킬 & 에이전트" icon="robot" href="/l/ko/developers/extend/apps/logic/skills-and-agents">
재사용 가능한 AI 에이전트 지시문과 맞춤형 시스템 프롬프트를 사용하는 어시스턴트입니다.
</Card>
<Card title="연결" icon="plug" href="/l/ko/developers/extend/apps/logic/connections">
Linear, GitHub, Slack 등 서드파티 서비스를 위해 앱이 보유한 OAuth 자격 증명입니다.
</Card>
<Card title="키-값 저장소" icon="database" href="/l/ko/developers/extend/apps/logic/key-value-store">
로직 함수 실행 사이에 상태를 유지합니다 — 캐시, 커서, 그리고 워크스페이스 간 클레임.
</Card>
<Card title="백그라운드 작업" icon="layer-group" href="/l/ko/developers/extend/apps/logic/background-jobs">
워커에서 로직 함수를 실행하도록 대기열에 추가하여 실행당 타임아웃을 우회하세요.
</Card>
</CardGroup>
## 한눈에 보는 트리거 유형
로직 함수는 하나 이상의 트리거를 선택합니다. 아래의 각 항목은 `defineLogicFunction()`의 개별 필드입니다:
| 트리거 | 실행 시점 | 설정 |
| -------------- | ---------------------------------------------- | ------------------------------- |
| **HTTP 경로** | 요청이 `/s/\<path>` 엔드포인트에 도달할 때 | `httpRouteTriggerSettings` |
| **크론** | CRON 표현식이 일치할 때 | `cronTriggerSettings` |
| **데이터베이스 이벤트** | 워크스페이스 레코드가 생성, 업데이트 또는 삭제될 때 | `databaseEventTriggerSettings` |
| **AI 도구** | Twenty AI 기능이 사용자의 함수를 호출하기로 결정할 때 | `toolTriggerSettings` |
| **워크플로우 액션** | 워크플로우 단계가 사용자의 함수를 호출할 때 | `workflowActionTriggerSettings` |
함수는 격리된 Node.js 프로세스의 샌드박스 환경에서 실행되며, [`defineApplication()`](/l/ko/developers/extend/apps/config/application)에 선언된 역할 범위에 맞춰 지정된 타입의 API 클라이언트를 통해 워크스페이스에 접근합니다.
<Note>
**설치 시점 후크** — 설치 전후에 실행되는 코드 — 는 동일한 런타임을 공유하지만, 별도의 define 함수들을 사용하며 [Config → Install Hooks](/l/ko/developers/extend/apps/config/install-hooks) 아래에 위치합니다.
</Note>

View File

@@ -0,0 +1,182 @@
---
title: 스킬 & 에이전트
description: 앱용 AI 스킬 및 에이전트 정의.
icon: robot
---
<Warning>
스킬과 에이전트는 현재 알파 단계입니다. 해당 기능은 작동하지만 아직 발전 중입니다.
</Warning>
앱은 워크스페이스 내에서 동작하는 AI 기능을 정의할 수 있습니다 — 재사용 가능한 스킬 지침과 사용자 지정 시스템 프롬프트를 갖춘 에이전트.
<AccordionGroup>
<Accordion title="defineSkill" description="AI 에이전트 스킬 정의">
스킬은 워크스페이스 내에서 AI 에이전트가 사용할 수 있는 재사용 가능한 지침과 기능을 정의합니다. `defineSkill()`을 사용해 내장 검증과 함께 스킬을 정의하세요:
```ts src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
핵심 요점:
* `name`은 스킬의 고유 식별자 문자열입니다(케밥 케이스 권장).
* `label`은 UI에 표시되는 사람이 읽기 쉬운 표시 이름입니다.
* `content`에는 스킬 지침이 포함됩니다 — 이는 AI 에이전트가 사용하는 텍스트입니다.
* `icon` (선택 사항)은 UI에 표시되는 아이콘을 설정합니다.
* `description` (선택 사항)은 스킬의 목적에 대한 추가 컨텍스트를 제공합니다.
</Accordion>
<Accordion title="defineAgent" description="사용자 지정 프롬프트로 AI 에이전트를 정의하세요">
에이전트는 워크스페이스 내에서 동작하는 AI 비서입니다. `defineAgent()`를 사용하여 사용자 지정 시스템 프롬프트로 에이전트를 생성하세요:
```ts src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'Helps the sales team draft outreach emails and research prospects',
icon: 'IconRobot',
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
});
```
핵심 요점:
* `name`은 에이전트의 고유 식별자 문자열입니다(케밥 케이스 권장).
* `label`은 UI에 표시되는 표시 이름입니다.
* `prompt`는 에이전트의 동작을 정의하는 시스템 프롬프트입니다.
* `description` (선택 사항)은 에이전트가 수행하는 작업에 대한 컨텍스트를 제공합니다.
* `icon` (선택 사항)은 UI에 표시되는 아이콘을 설정합니다.
* `modelId` (선택 사항)은 에이전트가 사용하는 기본 AI 모델을 재정의합니다.
* `responseFormat`(선택 사항)은 에이전트 출력의 형태를 제어합니다. 자유 형식 텍스트의 기본값은 `{ type: 'text' }`입니다. 구조화된 JSON 출력을 강제하려면 `{ type: 'json', schema }`를 사용하세요.
기본적으로 에이전트는 자유 형식 텍스트를 반환합니다. 구조화된 출력을 받으려면, `responseFormat`을 `{ type: 'json' }`으로 설정하고 `schema`를 제공하세요:
```ts src/agents/structured-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
name: 'lead-scorer',
label: 'Lead Scorer',
prompt: 'Score the lead and explain your reasoning.',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
score: { type: 'number', description: 'Lead score from 0 to 100' },
summary: { type: 'string', description: 'Short reasoning for the score' },
},
required: ['score', 'summary'],
additionalProperties: false,
},
},
});
```
스키마 참고:
* 스키마는 단일 수준의 객체입니다. 각 속성의 `type`은 반드시 기본형(`string`, `number`, 또는 `boolean`)이어야 합니다. 중첩 객체와 배열은 지원되지 않습니다.
* 각 속성의 `description`(선택 사항)은 해당 위치에 무엇을 넣어야 하는지 모델을 안내합니다.
* `required` (선택 사항)은 모델이 항상 반환해야 하는 속성을 나열합니다.
* `additionalProperties: false` (선택 사항)은 `properties`에 선언되지 않은 모든 속성을 금지합니다.
</Accordion>
<Accordion title="runAgent" description="로직 함수에서 에이전트를 실행">
`runAgent()`를 사용하면 로직 함수가 앱의 에이전트(해당 skills 및 tools 포함) 중 하나를 실행할 수 있습니다. `defineAgent()`에 전달한 `universalIdentifier`로 에이전트를 식별하세요: `prompt` 문자열 또는 `messages` 대화 기록 중 하나만 전달하고, 둘 다 전달하지는 마세요:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
});
```
멀티 턴 봇(Slack, Discord, Teams 등)의 경우, 단일 `prompt` 대신 스레드 기록을 `messages`로 전달하세요:
```ts src/logic-functions/reply-in-thread.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
messages: [
{ role: 'user', content: 'Who owns Acme?' },
{ role: 'assistant', content: 'Sarah owns the Acme account.' },
{ role: 'user', content: 'What was the last touchpoint?' },
],
});
```
핵심 요점:
* `prompt`(문자열) 또는 `messages`(1개에서 100개의 `{ role: 'user' | 'assistant', content: string }` 항목) 중 **정확히 하나만** 제공해야 합니다.
* 에이전트는 **동기적으로** 실행되며, 자체 tools를 통해 레코드를 읽고/업데이트할 수 있습니다. 실행이 완료되면 `runAgent()`가 resolve됩니다.
* 앱은 자신의 에이전트만 실행할 수 있습니다.
* 앱의 [기본 역할](/l/ko/developers/extend/apps/config/roles)은 `AI` 권한 플래그를 부여해야 합니다. 이를 위해 `permissionFlagUniversalIdentifiers`에 `SystemPermissionFlag.AI`를 추가하거나, `canAccessAllTools: true`로 설정하세요.
이 권한이 없으면 `runAgent()`는 권한 오류로 실패합니다.
* 로직 함수에 충분히 큰 `timeoutSeconds`를 설정하세요. 에이전트 실행에는 여러 초가 걸릴 수 있습니다.
* 실행이 완료되면 `success`는 `true`이고 `result`는 null이 아닙니다. 실패 시 `success`는 `false`, `result`는 `null`이며, `error`에 이유가 들어 있습니다(예: 실행 도중 워크스페이스의 AI 크레딧이 소진된 경우).
```ts src/roles/default-role.ts
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
export default defineApplicationRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
// runAgent() requires the AI permission flag on the app's default role.
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
});
```
<Warning>
**루프를 피하세요:** `*.updated` 데이터베이스 이벤트 트리거에서 `runAgent()`를 호출하고 에이전트가 동일한 레코드를 업데이트하는 경우, `updatedFields`를 에이전트가 절대 쓰지 않는 필드(예: 원본 URL)로 한정하거나, `runAgent()`를 호출하기 전에 대상 필드 중 어느 것이든 아직 비어 있는지 여부를 검사해 가드하세요.
</Warning>
### 워크스페이스 멤버를 대신해 실행하기
실행이 사람에 의해 트리거되는 경우(예: 메시지에 응답하는 챗봇) `runAsWorkspaceMemberId`를 전달하면, 에이전트가 앱이 아니라 해당 멤버로서 동작합니다:
```ts src/logic-functions/answer-question.ts
const { result } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'How many open opportunities do we have?',
runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
});
```
실행은 이후 **구성원의 고유 역할**로 동작합니다. 해당 구성원이 할 수 있는 작업만 수행할 수 있으며, 그 이상은 할 수 없습니다. 실행이 생성하는 레코드는 해당 구성원에게 귀속되고, 구성원의 행(row) 수준 권한이 적용됩니다. 에이전트의 역할은 여기에 관여하지 않습니다. 이는 배후에 사람이 없는 실행에 대해 애플리케이션의 기본값으로 사용됩니다. 자율 실행(예약된 작업, 데이터베이스 이벤트 트리거)에는 이 필드를 생략하세요. 이러한 실행은 에이전트의 고유 역할을 유지합니다. 동작 중인 애플리케이션은 출처(provenance)를 위해 실행의 컨텍스트에 계속 연결되어 있지만, 구성원의 권한 범위를 축소하지는 않습니다.
실행을 트리거한 사람을 워크스페이스 멤버와 매핑하는 책임은 앱에 있습니다. 이름을 지정하려면 앱 액세스 토큰이 필요하며, 토큰이 어떤 멤버를
지정할 수 있는지는 그 토큰에 사용자가 포함되어 있는지 여부에 따라 달라집니다:
* **사용자가 연결되지 않은** 토큰은 어떤 멤버든 이름을 지정할 수 있습니다. 하나를 사용해 로직 함수가 실행되며,
`client_credentials`를 통해 발급된 토큰이나 API 키에서 발급된 토큰도 마찬가지입니다.
* 프론트 컴포넌트가 받는 것처럼 **사용자를 대신해 발급된** 토큰은 해당 사용자의 멤버만 지정할 수 있습니다.
그 밖의 것들 — 일반 사용자 세션, 앱 토큰이 없는 API 키 — 은 멤버를 전혀 지정할 수 없습니다.
<Warning>
`runAgent()`는 워크스페이스 멤버를 확인할 수 없을 때 — 알 수 없거나 제거되었거나, 역할이 없는 멤버인 경우 — 예외를 발생시킵니다. 호출자가 요청한 것보다 더 많은 액세스를 부여하게 되므로,
에이전트 자신의 역할로는 절대 폴백하지 않습니다.
</Warning>
</Accordion>
</AccordionGroup>