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 のワーカーに、アプリのロジック関数のいずれかを後で実行するよう依頼します。ペイロードごとに 1 回、各実行は独自のプロセスと独自のタイムアウト枠で行われます。 呼び出し元はすぐに戻ります。
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## 実行をエンキューする
`twenty-sdk/logic-function` から `enqueueJobs` をインポートし、実行するロジック関数の `universalIdentifier` を指定して、エンキューする実行ごとに 1 つのペイロードを渡します。
```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` で拒否され、何もエンキューされません。 1 回の呼び出しで最大 `200` 個のペイロードを受け付けます。
<Note>
`enqueueJobs` は、ジョブが受け付けられた時点ですぐに戻り、ジョブが実行された時点では戻りません。 対象の結果は返しません。結果を後から読み戻す必要がある場合は、各対象で生成したものを [key-value store](/l/ja/developers/extend/apps/logic/key-value-store) またはワークスペースレコードに書き込ませてください。
</Note>
<Note>
呼び出しごとに単一のジョブをエンキューする古い `enqueueJob` ヘルパーは非推奨です。 代わりに、要素が 1 つの `payloads` リストで `enqueueJobs` を使用してください。
</Note>
## ジョブオプション
オプションはバッチ内のすべての実行に適用されます。
| オプション | デフォルト | 範囲 | 機能 |
| ------------ | ----- | ------------------------ | --------------------------------------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | 全体の追加キュー試行。 アプリケーションが要求する再試行回数は `3` に制限されます。 2 回実行しても安全なハンドラーに対してのみ、この値を増やしてください。 |
| `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 は、アプリケーションコードからのすべての例外を再試行するわけではありません。 通常のスローされたエラーは、永続的な障害として扱われます。 一時的な障害の場合、キューに入れられたロジック関数は `RetryableLogicFunctionError` をスローすることで、最大 3 回の再試行をリクエストできます。
```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` を直接スローしてください。 これを拡張する場合は、その `name` を置き換えないでください。Twenty は、実行ランタイム間でシリアル化された名前 `RetryableLogicFunctionError` を認識します。
`retryCount` は初回実行時には `0` であり、アプリケーションコードが再試行をリクエストした場合にのみ増加します。 `maxRetries` は最大で `3` であり、キューに入れられたジョブの全体的な再試行上限がより小さい場合は、これより低くなることがあります。 プラットフォーム障害は、キューの全体的な安全性バジェットを消費しますが、`retryCount` を増加させません。
キューは、指数バックオフとジッターを使用して再試行を遅延させます。 正確な遅延時間は意図的に保証されていないため、アプリケーションコードは再試行が正確な時刻に行われることに依存しないでください。 `maxRetries` に達すると、別の実行を行わずに、次の `RetryableLogicFunctionError` が最終的なアプリケーション障害として記録されます。
<Warning>
再試行ではハンドラー全体が再実行され、一部の副作用が成功した後に実行される場合があります。 再試行をリクエストする前に、ハンドラーを冪等にしてください。
</Warning>
## 使いどころ: 長い同期処理をページングする
典型的なパターンは、次のカーソルを指定して*自分自身*をエンキューする関数です。 各実行は、自身のタイムアウト内に十分収まる 1 ページ分の処理だけを行い、処理対象がなくなったところでチェーンが停止します。
```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,
});
```
## レコードごとにファンアウトする
処理が本質的にアイテムごとの場合は、1 回の呼び出しでアイテムごとに 1 つのジョブをエンキューし、インラインでループするのではなく、ワーカーに並列で処理させます。
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payloads: companies.map((company) => ({ companyId: company.id })),
retryLimit: 2,
});
```
## 長時間実行タスクのためのベストプラクティス
ほとんどの長時間ジョブには、次の 2 つのルールで対応できます。**ループの代わりに再帰させること**、そして **1 回の実行で処理するチャンクを制限すること**。
すべてを 1 回の実行で片付けようとするのは失敗パターンです。タイムアウトに達し、リトライがかかると処理全体をまた最初からやり直すことになります。 代わりに、1 つのチャンクを `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` に収まる必要があります。そうでないと、実行が打ち切られた際にチャンクの末尾が失われます。
* **終了条件を明示的にします。** チャンクがフルで返ってきた間だけ再帰させます。 「結果が 0 件」のみで停止するチェーンは、途中で短いページが返されることがあるソースに対しては、永久に動き続けてしまいます。
* **次の実行をエンキューする前に進捗を永続化**しておきます。そうすることで、どこかのリンクが失敗しても、最初からではなく最後に完了したチャンクから再開できます。
* **各チャンクはべき等に保ちます。** リトライ後に 1 つのチャンクを再処理しても二重書き込みにならないように、処理対象のレコードや外部 ID をキーにして書き込みを行ってください。
* **レート制限のあるサードパーティを相手にする場合は、巨大な 1 回のファンアウトよりもチャンク化したチェーンを優先**します。`delayMs` を設定したチェーンは自分でペース配分しますが、何千ものジョブを一度にエンキューすると、すべてが即座にキュー対象になってしまいます。

View File

@@ -0,0 +1,269 @@
---
title: 接続
description: OAuth を介して、アプリがユーザーに代わってサードパーティのサービスで操作を行えるようにします。
icon: plug
---
接続(Connection)は、ユーザーが外部サービス(Linear、GitHub、Slack など)のために保持する認証情報です。 あなたのアプリは、それらの認証情報を**どのように**取得するか — **接続プロバイダー** — を宣言し、実行時にそれらを利用してサードパーティの API へ認証付きの呼び出しを行います。
現在は OAuth 2.0 のみサポートされています。 将来的な認証情報タイプ(パーソナルアクセストークン、API キー、Basic 認証)も同じインターフェースに差し込まれます — すでに `defineConnectionProvider({ type: 'oauth', ... })` を使用しているアプリは移行の必要がありません。
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="アプリの接続をどのように取得するかを宣言する">
接続プロバイダーは、アプリに必要な OAuth のハンドシェイクを記述します。 ユーザーがアプリの設定で"接続を追加"をクリックし、プロバイダーの同意画面を完了すると、そのワークスペースに `ConnectedAccount` の行が作成されます。
動作するセットアップには、**2 つのファイル**が必要です — 接続プロバイダーと、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,
},
},
});
```
主なポイント:
* `listConnections({ providerName })` で使用される一意の識別子文字列が `name` です(kebab-case、`^[a-z][a-z0-9-]*$` に一致する必要があります)。
* `displayName` はアプリごとの設定タブおよび AI ツール一覧に表示されます。
* `clientIdVariable` / `clientSecretVariable` は値ではなく**名前**です — `defineApplication.serverVariables` で宣言されたキーと一致している必要があります。 実際の `client_id` と `client_secret` は、アプリ登録 UI を通じてサーバー管理者が入力し、あなたのリポジトリにコミットされることはありません。
* `applicationVariables` ではなく `serverVariables` を使用してください — OAuth の認証情報はサーバー全体で共有され、Twenty サーバーごとに 1 つの 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/ja/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 フックと同様に、**切断処理中のワークスペースで非同期に実行され**、切断をブロックすることはありません。 ハンドラーは同じペイロード構造を受け取ります:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
フックが実行される時点では `ConnectedAccount` はすでに削除されているため、`getConnection(connectedAccountId)` はもう解決されません。 クリーンアップに必要なもの(`team_id` や外部サブスクリプション ID など)は、接続時に `connectedAccountId` をキーとして [key-value store](/l/ja/developers/extend/apps/logic/key-value-store) に書き込まれている必要があります。
このフックは、接続が単独で削除されたときに発火します。 アプリをアンインストールすると、代わりにデータベースのカスケードによって接続が削除されるため、その場合はこのフックは実行されません。 その経路用に `defineApplication` 上で `uninstallLogicFunction` を宣言します。これはアプリのメタデータが削除される前に実行されるため、まだ `listConnections` を呼び出して残っているものをクリーンアップできます。
</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'`(ワークスペースの 1 メンバー専用)または `'workspace'`(全メンバーと共有) |
| `scopes` | 連携先プロバイダーによって付与された OAuth 権限(`visibility` とは別で、無関係です) |
| `userWorkspaceId` | 所有者の userWorkspace ID — HTTP ルートトリガーで"要求ユーザーの接続"を選ぶのに役立ちます |
| `accessToken` | 新しい OAuth アクセストークン(期限切れの場合は自動更新) |
| `name` | 接続の表示名(OAuth コールバック時に自動取得。ユーザーが名称変更可能) |
| `handle` | 接続された上流アカウントのメールアドレスは、再接続ごとに更新されます。 トークン交換時に返されたOIDC `id_token`を解決し、プロバイダが1つを返さないときに接続するTwentyユーザーの電子メールに戻ります |
| `authFailedAt` | 直近の更新に失敗した場合に設定されます。ユーザーは再接続する必要があります。 |
主なポイント:
* プロバイダーで絞り込むには `{ providerName }` を渡します。省略すると、このアプリが保有するすべてのプロバイダーの接続を取得します。
* サーバーは返却前にアクセストークンを透過的に更新します。 ハンドラーは常に利用可能なトークン(または `authFailedAt` が設定された状態)を受け取ります。
* `getConnection(id)` は単一行の取得に相当します。
</Accordion>
<Accordion title="ユーザー単位 vs ワークスペース共有の可視性" 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. その後、ワークスペースメンバーはアプリごとの **Connections** セクションから接続を追加できます。
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: キーバリューストア
description: 組み込みのアプリケーション用キー・バリュー ストアを使って、中間結果を永続化し、データをキャッシュし、ロジック関数の実行間で状態を共有します。
icon: database
---
ロジック関数は短命な Node.js プロセス内でサンドボックス実行されます。1 回の実行が終了すると、メモリ上に保持されていたものは何も残りません。 実行間で**何かを記憶しておく必要がある**場合(高コストな API レスポンスのキャッシュ、増分同期のためのカーソルの保存、処理のデバウンス、ある関数から別の関数への状態の受け渡しなど)、組み込みのキー・バリュー ストアに永続化します。
各アプリケーションには独自の分離された名前空間が割り当てられます。エントリは認証済みアプリ単位でキー付けされるため、別のアプリケーションとキーが衝突したり、読み取られたりすることは決してありません。
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## 取得、設定、削除
`kv` を `twenty-sdk/logic-function` からインポートします。 値には、任意の 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`** — エントリは、サーバー上の**すべてのインストール**間で共有されます。 サーバーエントリは **クレーム** のように動作します。保存される値は常にキーをクレームした workspaceId であり(現在のワークスペースでキーをクレームするには、`set` 時に `value` を省略します)、そのワークスペースだけが上書きや削除を行えます。 どのインストールからでも、そのエントリを読み取ることができます。
サーバークレームは、ワークスペース間ルーティングのために存在します。 [server-route resolver](/l/ja/developers/extend/apps/logic/logic-functions#server-route-trigger) はアプリケーション登録オーナーのワークスペース内で実行されますが、受信 Webhook は通常、外部アカウント ID のみを保持しており、Twenty の workspaceId は持っていません。 各ワークスペースが接続時に自分の外部 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` は例外をスローします。
## 使ってみる:高コストな呼び出しをキャッシュする
典型的な用途としては、遅い、またはレート制限されたサードパーティのレスポンスをキャッシュしておき、毎回コストを支払うのではなく、繰り返しの実行で再利用することが挙げられます。
```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/ja/developers/extend/apps/logic/logic-functions) から古くなったキーを定期的にクリアします。
* **何を保存するか。** 数値、文字列、配列、オブジェクトなど、任意の JSON シリアライズ可能な値を保存できます。 エントリは小さく保ってください。これは調整やキャッシュのためのものであり、大きな BLOB やファイル用ではありません。 ファイルには `FILES` フィールドと [`uploadFile`](/l/ja/developers/extend/apps/logic/logic-functions#uploading-files) を使用してください。
* **可視性。** エントリはインスタンス データベースに保存され、ワークスペースレコードとしては存在しません。そのためワークスペースの UI に表示されることはなく、アプリのデータモデルの一部にもならず、ロールやオブジェクトの権限設定も不要です。
## 代替案:クエリ可能なストアオブジェクト
組み込みストアは、あえて中身が見えないように設計されています。エントリはレコードではないため、UI で閲覧したり、他のオブジェクトと関連付けたり、レコードクエリでフィルタしたりすることはできません。 そうしたものが必要な場合、たとえば可視化された同期ログやレコード単位の状態などには、代わりに一意な `key` フィールドと `RAW_JSON` の `value` フィールドを持つ小さな**テクニカルオブジェクト**を定義し、[typed API client](/l/ja/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk) を通じてクエリします。 `defineObject` のリファレンスについては [Objects](/l/ja/developers/extend/apps/data/objects) を、キーの一意性を強制する方法については [Data → Unique indexes](/l/ja/developers/extend/apps/data/overview#unique-indexes) を参照してください。
* **レコードへのスコープ設定。** id をキーにエンコードするのではなく、ストアオブジェクトから対象オブジェクトへの [relation](/l/ja/developers/extend/apps/data/relations) を追加して関連付けます。
* **可視性と権限。** 行は他のレコードと同様にワークスペースのデータベース内に存在するため、API を通じてクエリでき、アプリの [role](/l/ja/developers/extend/apps/config/roles) に従った権限が適用されます。 ストアをメイン UI から隠しておきたい場合は、[navigation menu](/l/ja/developers/extend/apps/layout/navigation-menu-items) に追加しないでください。
<Note>
組み込みストアとは異なり、カスタムオブジェクトは常に 1 つのワークスペースにスコープされます。`SERVER` キーのように、インストール間でエントリを共有することはできません。
</Note>

View File

@@ -0,0 +1,909 @@
---
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/ja/developers/extend/apps/layout/front-components#calling-a-logic-function)を参照してください。
</Note>
* **cron**: CRON 式を使用してスケジュールで関数を実行します。
* **databaseEvent**: ワークスペースのオブジェクトのライフサイクルイベントで実行されます。 イベント操作が `updated` の場合、監視する特定のフィールドを `updatedFields` 配列で指定できます。 未定義または空のままにすると、任意の更新でも関数がトリガーされます。
> 例:`person.updated`、`*.created`、`company.*`
* **serverRoute**: 1 つの登録スコープの HTTP ルートを公開します。 **resolver** 関数(`serverRouteTriggerSettings` で宣言)は、オーナーワークスペースで実行され、同期的な `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` オブジェクトを受け取ります。
`RoutePayload` 型を `twenty-sdk/logic-function` からインポートします:
```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 方式の Webhook 署名を検証する際に役立ちます(例: GitHub の `X-Hub-Signature-256`、Stripe)。 ランタイムがそれを保持しなかった場合は `undefined` です。 | |
| `isBase64Encoded` | `boolean` | body が 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/ja/developers/extend/apps/getting-started/project-structure#dependencies) を参照してください。
#### サーバールートトリガー
`httpRouteTriggerSettings` は `/s/` 配下に関数を公開し、リクエストホストからワークスペースを特定します。これは、各ワークスペースが独自ドメインを持つ場合に機能します。 しかし、サードパーティプロバイダーは、すべてのテナントのイベントを **1 つの** URL に配信します。 その場合は、`serverRouteTriggerSettings` を使用します。
トリガーには 2 つの構成要素があります:
1. **resolver** ロジック関数 — `serverRouteTriggerSettings` で宣言される — は、**オーナーワークスペース**(アプリケーション登録を所有するワークスペース)で実行されます。 この関数は受信リクエストを検査し、次のいずれかを返します。
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — プラットフォームは解決済みワークスペースでその target をキューに入れ、`202 { queued: true }` で ACK します、または
* `twenty-sdk/logic-function` からの `Response` — プラットフォームはその HTTP レスポンスを**同期的に**エコーし、ターゲットをキューに入れることは**ありません**(Slack の `url_verification` のようなチャレンジハンドシェイクに使用します)。
resolver は単一の認可ポイントであり、URL には resolver の識別子のみが含まれます。 **ここがリクエスト署名を検証するための推奨箇所です**。resolver は副作用が発生する前に実行され、元の `rawBody` と転送されたヘッダーにアクセスでき、ターゲットに一切触れずにリクエストを拒否できます。
2. **target** ロジック関数 — 通常のワークスペース単位のロジック関数 — は、その後、resolver によって解決されたワークスペースで、resolver が返したペイロード(resolver が変換しなかった場合は元のリクエストペイロード)を使って実行されます。 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,
});
```
エンドポイントには次の URL でアクセスできます:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
識別子は、マニフェストに記載されている 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` のみです。 プロバイダーが2つ目の動詞を必要とする場合を除き、未設定のままにしてください。`GET` を宣言するルートでは、認証されていないすべての呼び出し元(予告なく `GET` を送信するクローラーやリンク展開ツールを含む)によってリゾルバーが実行されます。 プラットフォームにメソッドが存在しないものには、リゾルバーが実行されることなく `405` が返されます。
<Note>
**アプリケーションは所有者ワークスペースでクレームされ、インストールされている必要があります。** リゾルバーは **所有者ワークスペース**(アプリケーション登録を所有しているワークスペース)上で実行されるため、サーバールートトリガーが動作するのは、アプリケーションが*クレーム*されている、つまり所有者ワークスペースを持っていること **かつ** そのアプリケーションが **所有者ワークスペースにインストールされている** 場合のみです。 この2つの条件がどちらも満たされるまでは、リゾルバーを実行する場所が存在しないため、ルートをディスパッチできません。 したがって、`serverRouteTriggerSettings` ロジック関数を公開するアプリケーションは、所有者ワークスペースでクレームされインストールされるまで、マーケットプレイスに掲載することはできません。
</Note>
**Resolver の契約。** SDK の `LogicFunctionConfig` 型はコンパイル時にこれを強制します。`serverRouteTriggerSettings` を設定するとすぐに、ハンドラーは `Response` か、`{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(またはそのいずれかの `Promise`)を返すように制約されます。 dispatch パスでは、`workspaceId` は target 関数がインストールされているワークスペースである必要があります。そうでない場合、リクエストは `404` で拒否されます。 どちらの形にも一致しない結果(識別子が UUID でないものを含む)は、`502` で拒否されます。
| フィールド | タイプ | ノート |
| ---------------------------------------- | --------------- | -------------------------------------------- |
| `workspaceId` | `string` | ターゲットが実行されるワークスペースの UUID。 |
| `targetLogicFunctionUniversalIdentifier` | `string` | そのワークスペースで呼び出すロジック関数の `universalIdentifier`。 |
| `payload` | `object`(オプション) | 設定された場合、ターゲットに送信されるリクエストボディを置き換えます。 |
<Warning>
**署名の検証はあなたの責任です — resolver 内で検証してください。** プラットフォームはリクエスト署名を検証しません。 resolver はそれを行う推奨箇所です。最初に実行され、`event.rawBody` と、`forwardedRequestHeaders` に指定したヘッダーにアクセスできます。また、エラーをスローする(または一致しない `workspaceId` を返す)ことで、ターゲットが呼び出される前にディスパッチを停止できます。 代わりに検証処理を target 側に押し下げる場合、target は `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=` が付与されます) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex(先頭に `v0=` が付与されます) |
上記の resolver の例では、すでに GitHub の HMAC-SHA256 フローを示しています。連携するプロバイダーに合わせて、ヘッダー名、ダイジェストのエンコーディング、および署名対象となるペイロード文字列を調整してください。
<Note>
resolver が dispatch オブジェクトを返すと、ルートは `202 { queued: true }` を返し、target はワーカーキュー上で実行されます。呼び出し元は target のレイテンシ、結果、失敗を一切観測しません(それらは実行ログに記録されます)。 これにより、送信側の再配信によって処理の遅延が増幅されるのを防げます。これは、Webhook 取り込みにおいて望ましい動作です。
呼び出し元が同じリクエストでレスポンスボディを読み取る必要がある場合(チャレンジハンドシェイク、対話的な受領確認など)、代わりに **resolver** から `Response` を返してください。 プラットフォームはそれを同期的にエコーし、キューをスキップします。そのヘッダーは HTTP ルートレスポンスと同じ許可リストを通過します。 resolver は高速に保ってください。Slack など一部のプロバイダーは数秒でタイムアウトします。 resolver はパブリックエンドポイントとして到達可能であるため、エッジでレート制限をかけて保護してください。
</Note>
#### データベースイベントトリガーのペイロード
データベースイベントトリガーがロジック関数を呼び出すと、変更されたレコードごとに 1 つの `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,
};
};
```
#### 実行コンテキスト
すべてのハンドラーは、何によってトリガーされたかにかかわらず、実行自体を説明する2番目の引数を受け取ります。 最初の引数はトリガーごとに形式が変わりますが、こちらは変わりません:
```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/ja/developers/extend/apps/logic/background-jobs)を参照してください。 |
| `maxRetries` | `number` | アプリケーションが要求する再試行回数の上限。 |
誰も実行をトリガーしていない場合、`userWorkspaceId` と `workspaceMemberId` は `null` です。cron スケジュール、インストールフック、および認証されていない Webhook には、それに対応する人物がいません。 その人物にワークスペースメンバーのレコードがない場合、またはそのレコードが削除されている場合も、これらは `null` です。
<Note>
**コンテキストは、誰が実行をトリガーしたかを示します。** 実行で*できること*は、以下で説明する別の宣言です。
</Note>
#### 呼び出しで使用されるアクセス権
すべてのクライアント(`CoreApiClient`、`MetadataApiClient`、`RestApiClient`)は、実行をトリガーした人物として動作します。その人物のロールとアプリケーションのロールの共通部分が適用されるため、呼び出しでできることは、どちらか一方ができることを超えません。 これがデフォルトであり、人物がアプリを使用して自身の権限を超えることは決してできないことを意味します。
誰も実行をトリガーしていない場合、動作する人物がいないため、同じクライアントはアプリケーション自身のアクセス権にフォールバックします。cron スケジュール、インストールフック、および認証されていない Webhook には特別な処理は必要ありません。
人物が実行の背後に*いる*場合でも、アプリケーション自身のアクセス権を正当に必要とする呼び出しがあります。たとえば、アプリの設定レコードの読み取りや、その人物自身では実行できない作業です。 そのために2つ目のクライアントを作成します:
```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 スケジュール、インストールフック、および認証されていない Webhook には、それに対応する人物がいないため、デフォルトのクライアントはアプリケーション自身のアクセス権にフォールバックして動作し続けます。 `runAs: 'application'` が必要なのは、人物が実行をトリガーした場合にそのアクセス権を使用したいときだけです。
たとえばレコードの帰属先を指定するためなど、誰かが背後にいるかどうかによって関数の動作が異なる場合は、`context.workspaceMemberId` を確認してください。
</Note>
アプリケーション自身のリソースにアクセスする SDK ヘルパーは、常にそのアクセス権を使用し、`runAs` を無視します。[キー値ストア](/l/ja/developers/extend/apps/logic/key-value-store)、[接続](/l/ja/developers/extend/apps/logic/connections)、`runAgent`、`getPublicAssetUrl`、およびクレジットの請求が該当します。
#### 関数を AI ツールまたはワークフロー アクションとして公開する
ロジック関数は 2 つのサーフェス上で公開でき、それぞれに固有のトリガーがあります:
* **`toolTriggerSettings`** — Twenty の AI 機能(チャット、MCP、関数呼び出し)からその関数を見つけられるようにします。 標準的な JSON Schema を使用します。これは LLM がネイティブに理解する形式です。
* **`workflowActionTriggerSettings`** — ビジュアル ワークフロー ビルダー内のステップとして関数を表示します。 ビルダーが適切なフィールドエディタ、変数ピッカー、ラベルをレンダリングできるよう、Twenty の充実した `InputSchema` を使用します。
関数は一方、もう一方、またはその両方を選択できます。 これらは `cronTriggerSettings`、`databaseEventTriggerSettings`、`httpRouteTriggerSettings` と並列に存在します — 同じパターン、同じ形です。
<Note>
**ワークフローの Code アクションとの関係。** ワークフロービルダーに組み込まれている **Code** アクション自体もロジック関数です。Twenty は Code ステップごとに 1 つ作成し、そのエディタをインラインで表示します。 `workflowActionTriggerSettings` を使うことで、その使い捨てのインラインコードを**再利用可能な**アクションに変換できます。アプリ内でその関数を 1 度定義すれば、各 Code ステップにコードをコピーペーストする代わりに、任意のワークフローで選択できるようになります。 エンドユーザー側の表示については、ユーザーガイドの [Code action](/l/ja/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 Schema、ワークフロー アクションには 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` は 4 つのフィールドを受け取ります:
| フィールド | 目的 |
| -------------- | -------------------------------------------------------------------------------------------------- |
| `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` は、小さなランタイムヘルパーを再エクスポートすることで、ハンドラーが直接 `twenty-shared` からインポートする必要がないようにします。 たとえば、`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>
**インストールフック** — pre-install、post-install、uninstall の各ハンドラー — はこのランタイムを共有しますが、それぞれ独自の define 関数で宣言され、トリガー設定は受け取りません。 `definePreInstallLogicFunction`、`definePostInstallLogicFunction`、`defineUninstallLogicFunction` については、[インストールフック](/l/ja/developers/extend/apps/config/install-hooks) を参照してください。
</Note>
## タイムラインアクティビティを作成する
`createTimelineActivity()` を使用して、ロジック関数から明示的なドメインイベントを公開します。 まずイベントを[タイムラインアクティビティタイプ](/l/ja/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 クライアントを 2 つ提供します。
| クライアント | インポート | エンドポイント | 自動生成? |
| ------------------- | ---------------------------- | ------------------------------------ | ----------------- |
| `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,
},
});
```
このクライアントは selection-set 構文を使用します。フィールドを含めるには `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 ルート、cron スケジュール、データベースイベント、AI ツール、またはワークフローアクションによってトリガーされ、Twenty 内部で実行されるサーバーサイドの TypeScript。
icon: bolt
---
Twenty アプリの **ロジックレイヤー** は、*実行される* コードです。HTTP リクエスト、cron スケジュール、レコードの変更に反応するサーバーサイドの 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/ja/developers/extend/apps/logic/logic-functions">
コアとなる構成要素 — トリガーの種類、ペイロード、および型付き API クライアント。
</Card>
<Card title="スキルとエージェント" icon="robot" href="/l/ja/developers/extend/apps/logic/skills-and-agents">
再利用可能な AI エージェント向けの指示や、カスタムのシステムプロンプトを備えたアシスタント。
</Card>
<Card title="接続" icon="plug" href="/l/ja/developers/extend/apps/logic/connections">
Linear、GitHub、Slack など、サードパーティサービス向けにアプリが保持する OAuth 資格情報。
</Card>
<Card title="キーバリューストア" icon="database" href="/l/ja/developers/extend/apps/logic/key-value-store">
ロジック関数の実行間で状態を永続化します ― キャッシュ、カーソル、ワークスペース間のクレーム。
</Card>
<Card title="バックグラウンドジョブ" icon="layer-group" href="/l/ja/developers/extend/apps/logic/background-jobs">
ワーカー上でロジック関数の実行をキューに入れて、実行ごとのタイムアウト制限を回避します。
</Card>
</CardGroup>
## トリガータイプの概要
ロジック関数は 1 つ以上のトリガーを選択します。以下の各項目は、`defineLogicFunction()` 上の個別のフィールドです。
| トリガー | 実行タイミング | 設定 |
| --------------- | --------------------------------------------------- | ------------------------------- |
| **HTTP ルート** | リクエストが `/s/\<path>` エンドポイントに到達したとき | `httpRouteTriggerSettings` |
| **クロン** | CRON 式が一致したとき | `cronTriggerSettings` |
| **データベースイベント** | ワークスペースのレコードが作成、更新、または削除されたとき | `databaseEventTriggerSettings` |
| **AI ツール** | Twenty の AI 機能が関数を呼び出すことを決定したとき | `toolTriggerSettings` |
| **ワークフローアクション** | ワークフローステップが関数を呼び出したとき | `workflowActionTriggerSettings` |
関数は分離された Node.js プロセス内でサンドボックス実行され、[`defineApplication()`](/l/ja/developers/extend/apps/config/application) で宣言されたロールにスコープされた型付き API クライアントを通じてワークスペースにアクセスします。
<Note>
**インストール時フック** — インストールの前後に実行されるコード — はこのランタイムを共有しますが、独自の define 関数を使用し、[Config → Install Hooks](/l/ja/developers/extend/apps/config/install-hooks) の下に配置されます。
</Note>

View File

@@ -0,0 +1,181 @@
---
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` はスキルの一意の識別子文字列です(kebab-case を推奨)。
* `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` はエージェントの一意の識別子文字列です(kebab-case を推奨)。
* `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()` を使用すると、ロジック関数からアプリのエージェント(そのスキルやツールを含む)を実行できます。 `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` (string) または `messages`(1~100 個の `{ role: 'user' | 'assistant', content: string }` エントリ)のうち、**ちょうど一つだけ**を指定してください。
* エージェントは**同期的に**実行され、自身のツールを通じてレコードの読み取り/更新ができます。`runAgent()` は、実行が完了すると解決されます。
* アプリは自分自身のエージェントのみを実行できます。
* アプリの[デフォルトロール](/l/ja/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',
});
```
この実行はその後、**メンバー自身のロール**で動作します。そのメンバーができることだけを実行でき、それ以上のことはできません。作成されたレコードはそのメンバーに帰属し、そのメンバーの行レベル権限が適用されます。 エージェントのロールは関与しません。これは、背後に誰もいない実行に対するアプリのデフォルトです。 自律的な実行(スケジュールされたジョブ、データベースイベントトリガー)の場合は、そのフィールドを省略します。この場合、エージェント自身のロールが維持されます。 動作中のアプリケーションは、メンバーの権限を狭めることなく、プロビナンスのために実行のコンテキストに紐づいたままになります。
実行をトリガーした人物を、どのワークスペースメンバーに対応付けるかは、あなたのアプリ側の責任です。 メンバーを指定するにはアプリアクセストークンが必要であり、トークンで指定できるメンバーは、そのトークンにユーザーが紐づいているかどうかに依存します。
* ユーザーが紐づいていないトークンは、任意のメンバーを指定できます。 ロジック関数はそのようなトークンで実行され、`client_credentials` を通じて、または API
キーから発行されたトークンも同様です。
* フロントコンポーネントが受け取るような、ユーザーを代表して発行されたトークンは、そのユーザー自身のメンバーのみを指定できます。
それ以外、つまり通常のユーザーセッションやアプリアクセストークンを伴わない API キーは、メンバーをまったく指定できません。
<Warning>
`runAgent()` は、ワークスペースメンバーを解決できない場合(不明または削除されたメンバー、あるいはロールを持たないメンバー)に例外をスローします。 呼び出し元が要求した以上のアクセス権を与えてしまうことになるため、
エージェント自身のロールにフォールバックすることは決してありません。
</Warning>
</Accordion>
</AccordionGroup>