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: Tâches en arrière-plan
description: Confiez les travaux longs ou soumis à des limitations de débit aux workers de Twenty en mettant en file d’attente une autre exécution de fonction logique au lieu de tout faire en ligne.
icon: layer-group
---
Une exécution de fonction logique est limitée par son `timeoutSeconds` (900 secondes maximum). Tout ce qui ne peut pas se terminer dans cette fenêtre — une resynchronisation complète, une diffusion par enregistrement, une API tierce qui vous applique des limitations de débit — doit être découpé en exécutions plus petites.
`enqueueJobs` fait exactement cela : il demande aux workers de Twenty d’exécuter plus tard l’une des fonctions logiques de votre application, une fois par charge utile, chaque exécution dans son propre processus avec son propre budget de délai d’expiration. La fonction appelante retourne immédiatement.
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## Mettre des exécutions en file d’attente
Importez `enqueueJobs` depuis `twenty-sdk/logic-function`, pointez-le vers le `universalIdentifier` de la fonction logique à exécuter et transmettez une charge utile par exécution pour la mise en file d’attente.
```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 }],
});
```
Chaque exécution reçoit sa charge utile comme argument du gestionnaire, exactement comme pour tout autre déclencheur. La cible doit appartenir à la **même application** que l’appelant — la mise en file d’attente de la fonction d’une autre application est rejetée avec `Logic function not found` et rien n’est mis en file d’attente. Un seul appel accepte jusqu’à `200` charges utiles.
<Note>
`enqueueJobs` renvoie dès que les jobs sont acceptés, et non pas lorsqu’ils ont été exécutés. Il ne renvoie pas les résultats des cibles — faites en sorte que chaque cible écrive ce qu’elle produit dans le [key-value store](/l/fr/developers/extend/apps/logic/key-value-store) ou dans un enregistrement d’espace de travail si vous devez les lire à nouveau.
</Note>
<Note>
L’ancien assistant `enqueueJob`, qui met en file d’attente un seul job par appel, est obsolète. Utilisez plutôt `enqueueJobs` avec une liste `payloads` à un élément.
</Note>
## Options du job
Les options s’appliquent à chaque exécution du lot.
| Option | Par défaut | Plage | Ce que cela fait |
| ------------ | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | Nombre total de tentatives supplémentaires dans la file d'attente. Les nouvelles tentatives demandées par l'application sont limitées à `3`. N’augmentez cette valeur que pour les gestionnaires qui peuvent être exécutés deux fois en toute sécurité. |
| `delayMs` | `0` | `0`–`604800000` (7 jours) | Attendez ce délai avant que les exécutions deviennent éligibles. |
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**La priorité n’est pas encore configurable.** Les jobs mis en file d’attente s’exécutent toujours avec la priorité la plus basse, de sorte que le travail de la plateforme n’est jamais retardé derrière les jobs des applications. Le contrôle de la priorité arrive bientôt.
</Note>
L’exécution mise en file d’attente hérite de l’utilisateur exécutant la fonction qui l’a mise en file d’attente, elle agit donc avec les mêmes autorisations.
## Réessayer après un échec transitoire.
Twenty ne réessaie pas toutes les exceptions du code d'application. Une erreur ordinaire levée est traitée comme un échec permanent. En cas d'échec transitoire, une fonction logique mise en file d'attente peut demander jusqu'à trois nouvelles tentatives en levant `RetryableLogicFunctionError`.
```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}`,
);
}
};
```
Levez directement `RetryableLogicFunctionError` lorsque cela est possible. Si vous l'étendez, ne remplacez pas son `name` : Twenty reconnaît le nom sérialisé `RetryableLogicFunctionError` dans les environnements d'exécution.
`retryCount` est égal à `0` lors de l'exécution initiale et n'augmente que lorsque le code d'application demande une nouvelle tentative. `maxRetries` est au maximum de `3` et peut être inférieur lorsque la tâche mise en file d'attente a une limite globale de nouvelles tentatives plus faible. Les défaillances de la plateforme n'augmentent pas `retryCount`, bien qu'elles consomment toujours le budget de sécurité global de la file d'attente.
La file d'attente retarde les tentatives avec un délai exponentiel et une gigue. Le délai exact n'est intentionnellement pas garanti, le code d'application ne doit donc pas dépendre d'une nouvelle tentative à un moment précis. Une fois `maxRetries` atteint, un autre `RetryableLogicFunctionError` est enregistré comme échec final de l'application sans nouvelle exécution.
<Warning>
Les nouvelles tentatives réexécutent l'intégralité du gestionnaire et peuvent survenir après la réussite de certains effets secondaires. Rendez le gestionnaire idempotent avant de demander des nouvelles tentatives.
</Warning>
## Utilisation : paginer une longue synchronisation
La forme classique est une fonction qui met *elle-même* en file d’attente la prochaine exécution avec le curseur suivant. Chaque exécution traite une page de travail bien à l’intérieur de son propre délai d’expiration, et la chaîne s’arrête lorsqu’il ne reste plus rien.
```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,
});
```
## Répartition par enregistrement
Quand le travail est naturellement par élément, mettez en file d’attente un job par élément dans un seul appel et laissez les workers les traiter en parallèle au lieu de boucler en ligne.
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payloads: companies.map((company) => ({ companyId: company.id })),
retryLimit: 2,
});
```
## Bonnes pratiques pour les tâches de longue durée
Deux règles couvrent presque toutes les longues tâches : **utilisez la récursion au lieu de boucles** et **traitez un bloc borné par exécution**.
Une exécution qui essaie de tout faire est un mode d’échec — elle atteint le délai d’expiration, et avec une nouvelle tentative elle recommence tout depuis zéro. Au lieu de cela, dimensionnez un bloc de manière à ce qu’il se termine confortablement dans `timeoutSeconds`, conservez votre position et mettez en file d’attente l’exécution suivante.
```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,
});
```
Ce qui rend cette approche robuste :
* **Dimensionnez le bloc à partir de l’élément le plus lent, pas de la moyenne.** `CHUNK_SIZE × worst-case item time` doit tenir dans `timeoutSeconds` avec une marge de sécurité, sinon la fin d’un bloc est perdue lorsque l’exécution est interrompue.
* **Rendez la condition de terminaison explicite.** Utilisez la récursion uniquement lorsqu’un bloc complet est revenu. Une chaîne qui s’arrête uniquement sur "no results" continuera indéfiniment si la source renvoie un jour une page courte en cours de route.
* **Conservez la progression avant de mettre en file d’attente l’exécution suivante,** afin qu’un maillon ayant échoué redémarre au dernier bloc terminé plutôt qu’au début.
* **Gardez chaque bloc idempotent.** Le retraitement d’un bloc après une nouvelle tentative ne doit pas provoquer une double écriture — indexez les écritures sur l’enregistrement ou l’identifiant externe que vous traitez.
* **Préférez une chaîne par blocs à un énorme déploiement parallèle** lorsque le travail touche un tiers soumis à des limitations de débit : une chaîne avec `delayMs` se régule elle-même, alors que des milliers de jobs mis en file d’attente d’un coup deviennent tous éligibles immédiatement.

View File

@@ -0,0 +1,269 @@
---
title: Connexions
description: Autorisez votre application à agir au nom d'un utilisateur auprès de services tiers via OAuth.
icon: plug
---
Les connexions sont des identifiants qu'un utilisateur détient pour un service externe (Linear, GitHub, Slack, ...). Votre application déclare **comment** ces identifiants sont obtenus — un **fournisseur de connexion** — et les consomme à l'exécution pour effectuer des appels authentifiés vers l'API tierce.
À ce jour, seul OAuth 2.0 est pris en charge. De futurs types d'identifiants (jetons d'accès personnels, clés d'API, authentification de base) s'intégreront à la même interface — les applications utilisant déjà `defineConnectionProvider({ type: 'oauth', ... `})\` n'auront pas besoin de migrer.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="Déclarez comment les connexions de votre application sont obtenues">
Un fournisseur de connexion décrit le processus d'échange OAuth requis par votre application. L'utilisateur clique sur "Ajouter une connexion" dans les paramètres de votre application, complète l'écran de consentement du fournisseur, et une ligne `ConnectedAccount` est créée dans son espace de travail.
Une configuration fonctionnelle nécessite **deux fichiers** — le fournisseur de connexion, et une déclaration `serverVariables` correspondante sur `defineApplication` qui contient les identifiants client OAuth.
```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,
},
},
});
```
Points clés :
* `name` est l'identifiant unique utilisé dans `listConnections({ providerName })` (kebab-case, doit correspondre à `^[a-z][a-z0-9-]*$`).
* `displayName` s'affiche dans l'onglet des paramètres par application et dans la liste des outils d'IA.
* `clientIdVariable` / `clientSecretVariable` sont des **noms**, pas des valeurs — ils doivent correspondre aux clés déclarées dans `defineApplication.serverVariables`. Les `client_id` et `client_secret` réels sont saisis par l'administrateur du serveur via l'interface d'enregistrement de l'application, et ne sont jamais validés dans votre dépôt.
* Utilisez `serverVariables` (pas `applicationVariables`) — les identifiants OAuth sont au niveau du serveur et il n'y a qu'une application OAuth par serveur Twenty.
* Tant que les deux `serverVariables` ne sont pas renseignées, l'onglet des paramètres par application affiche une indication "nécessite un administrateur du serveur" et le bouton "Ajouter une connexion" est désactivé.
* `type: 'oauth'` est la seule valeur prise en charge à ce jour. Le discriminateur est compatible avec les évolutions : des types futurs (`'pat'`, `'api-key'`, ...) ajouteront de nouveaux blocs de sous-configuration à côté de `oauth`.
L'URL de rappel OAuth que votre fournisseur doit autoriser est :
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="Exécuter une fonction logique lors de la connexion" description="Réagir au moment où une connexion est établie">
Certains fournisseurs vous transmettent, au moment de la connexion, des données que vous devez conserver avant que la connexion ne soit exploitable — l’exemple classique est Slack, où la réponse OAuth identifie le `team_id` de l’espace de travail sur lequel les événements entrants seront indexés. Définissez `onConnectLogicFunction` pour référencer une fonction logique dans la même application (par son `universalIdentifier`), et elle s’exécute juste après la création du `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',
},
});
```
Le hook s’exécute **de manière asynchrone dans l’espace de travail en cours de connexion** (il est mis en file d’attente, non attendu), de sorte qu’un hook lent ou défaillant ne bloque ni n’interrompt jamais le callback OAuth — rendez-le idempotent et faites en sorte qu’il gère ses propres réessais. Le gestionnaire reçoit :
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
À partir de là, utilisez `getConnection(connectedAccountId)` pour lire le jeton d’accès actualisé et appeler l’API du fournisseur (par exemple Slack `auth.test`) ou persister un mappage dans le [stockage clé-valeur](/l/fr/developers/extend/apps/logic/key-value-store).
</Accordion>
<Accordion title="Exécuter une fonction logique lors de la déconnexion" description="Nettoyer lorsqu’une connexion est supprimée">
Tout ce qu’une application revendique au moment de la connexion doit être libéré lorsque la connexion est supprimée. Une intégration Slack qui revendique un `team_id` lors de la connexion, par exemple, doit libérer cette revendication afin qu’un autre espace de travail puisse connecter la même équipe Slack. Définissez `onDisconnectLogicFunction` pour référencer une fonction logique dans la même application, et elle s’exécute juste après la suppression du `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',
},
});
```
Comme le hook on-connect, il s’exécute **de manière asynchrone dans l’espace de travail en cours de déconnexion** et ne bloque jamais la déconnexion. Le gestionnaire reçoit la même structure de payload :
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
Le `ConnectedAccount` n’existe déjà plus au moment où le hook s’exécute, donc `getConnection(connectedAccountId)` ne se résout plus. Tout ce dont le nettoyage a besoin (un `team_id`, un identifiant d’abonnement externe) doit avoir été écrit dans le [key-value store](/l/fr/developers/extend/apps/logic/key-value-store) au moment de la connexion, indexé par `connectedAccountId`.
Le hook est déclenché lorsqu’une connexion est supprimée directement. La désinstallation de l’application supprime ses connexions via une cascade de base de données à la place, donc le hook ne s’exécute pas dans ce cas. Déclarez un `uninstallLogicFunction` sur `defineApplication` pour ce cas : il s’exécute avant que les métadonnées de l’application ne soient supprimées, ce qui lui permet encore d’appeler `listConnections` et de nettoyer tout ce qui reste.
</Accordion>
<Accordion title="listConnections / getConnection" description="Utilisez les connexions depuis une fonction logique">
Dans un gestionnaire de fonction logique, `listConnections({ providerName })` renvoie les lignes `ConnectedAccount` de cette application pour le fournisseur donné, avec des jetons d'accès actualisés.
```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 };
};
```
Chaque connexion comporte :
| Champ | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | ID de ligne unique ; à passer à `getConnection(id)` pour en récupérer une seule |
| `visibility` | `'user'` (réservée à un membre de l'espace de travail) ou `'workspace'` (partagée avec tous les membres) |
| `scopes` | Autorisations OAuth accordées par le fournisseur en amont (distinctes de `visibility` — sans rapport) |
| `userWorkspaceId` | L'ID userWorkspace du propriétaire — utile pour sélectionner "la connexion de l'utilisateur de la requête" dans les déclencheurs de routes HTTP |
| `accessToken` | Jeton d'accès OAuth à jour (actualisé automatiquement s'il a expiré) |
| `nom` | Le nom d'affichage de la connexion (déduit automatiquement lors du callback OAuth, renommable par l'utilisateur) |
| `handle` | L'e-mail du compte amont connecté, actualisé à chaque reconnexion; résolu à partir du `id_token` OIDC a retourné lors de l'échange de jetons, tombant à l'email de 20 utilisateurs de connexion quand le fournisseur ne retourne pas un |
| `authFailedAt` | Défini lorsque le rafraîchissement le plus récent a échoué ; l'utilisateur doit se reconnecter |
Points clés :
* Passez `{ providerName }` pour filtrer par fournisseur ; omettez-le pour obtenir toutes les connexions que cette application possède sur l'ensemble des fournisseurs.
* Le serveur actualise de manière transparente le jeton d'accès avant de répondre. Votre gestionnaire voit toujours un jeton utilisable (ou `authFailedAt` défini).
* `getConnection(id)` est l'équivalent pour une seule ligne.
</Accordion>
<Accordion title="Visibilité par utilisateur vs partagée à l'échelle de l'espace de travail" description="Comment les utilisateurs choisissent entre des identifiants privés et partagés">
Lorsqu'un utilisateur clique sur "Ajouter une connexion", il lui est demandé de choisir une visibilité :
* **Uniquement pour moi** — les identifiants sont privés pour l'utilisateur qui se connecte. Toute fonction logique appelée en son nom (déclencheur de route HTTP avec `isAuthRequired: true`) y a accès ; les déclencheurs cron et les événements de base de données n'y ont pas accès.
* **Partagé au niveau de l'espace de travail** — tout membre de l'espace de travail peut utiliser les identifiants. Les déclencheurs cron / base de données y ont également accès, puisqu'ils n'ont pas d'utilisateur de requête.
Utilisez le bon pour chaque gestionnaire :
```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');
```
Plusieurs connexions par (utilisateur, fournisseur) sont autorisées, ainsi le même utilisateur peut avoir "Linear personnel" et "Linear professionnel" côte à côte.
</Accordion>
<Accordion title="Configuration unique du fournisseur" description="Enregistrez votre application OAuth auprès du service tiers">
Pour chaque fournisseur de connexion, l'administrateur du serveur doit d'abord enregistrer une application OAuth auprès du service tiers.
1. Accédez aux paramètres développeur du fournisseur (par ex. https://linear.app/settings/api/applications/new).
2. Définissez l'**URI de redirection** sur `\<SERVER_URL>/auth/apps/callback`.
3. Copiez l'**ID client** et le **Secret client** générés.
4. Ouvrez l'application installée dans Twenty en tant qu'administrateur du serveur → définissez les valeurs sur les `serverVariables` correspondantes.
5. Les membres de l'espace de travail peuvent ensuite ajouter des connexions depuis la section **Connexions** par application.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: Stockage clé-valeur
description: Conservez des résultats intermédiaires, mettez les données en cache et partagez l’état entre les exécutions de fonctions logiques avec le magasin clé-valeur intégré à l’application.
icon: database
---
Les fonctions logiques s'exécutent dans des processus Node.js isolés et de courte durée — une fois une exécution terminée, rien de ce qui était conservé en mémoire ne survit. Lorsque vous avez besoin de **vous souvenir de quelque chose entre les exécutions** (mettre en cache une réponse d’API coûteuse, stocker un curseur pour des synchronisations incrémentales, appliquer un anti-rebond, ou transférer l’état d’une fonction à une autre), conservez-le dans le magasin clé-valeur intégré.
Chaque application dispose de son propre espace de noms isolé : les entrées sont associées à l’application authentifiée, de sorte que vos clés ne peuvent jamais entrer en collision avec celles d’une autre application, ni être lues par celle-ci.
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## Obtenir, définir, supprimer
Importez `kv` depuis `twenty-sdk/logic-function`. Les valeurs peuvent être n’importe quelle charge utile sérialisable en 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');
```
## Périmètres
Chaque entrée possède une portée, passée comme option à chaque appel. La valeur par défaut est `WORKSPACE`.
* **`WORKSPACE`** (par défaut) — l’entrée est réservée à l’installation actuelle de votre application dans l’espace de travail. Chaque espace de travail qui installe l’application obtient son propre ensemble indépendant de clés. C’est ce qu’il vous faut pour les caches, les curseurs et l’état par espace de travail.
* **`SERVER`** — l’entrée est partagée entre **toutes les installations** de votre application sur le serveur. Les entrées serveur se comportent comme des **revendications** : la valeur stockée est toujours le workspaceId qui a revendiqué la clé (omettre `value` lors de `set` pour revendiquer la clé pour l’espace de travail actuel), et seul cet espace de travail peut la remplacer ou la supprimer. N’importe quelle installation peut lire l’entrée.
Les revendications serveur existent pour le routage inter-espaces de travail. Un [résolveur de route serveur](/l/fr/developers/extend/apps/logic/logic-functions#server-route-trigger) s’exécute dans l’espace de travail propriétaire de l’enregistrement de l’application, mais un webhook entrant ne transporte généralement qu’un identifiant de compte externe — pas un workspaceId Twenty. Faites en sorte que chaque espace de travail revendique son identifiant externe au moment de la connexion, puis résolvez-le dans la route :
```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',
});
```
Comme une clé serveur ne peut être revendiquée que pour l’espace de travail de l’appelant et jamais écrasée par un autre, un espace de travail ne peut pas détourner un mappage appartenant à quelqu’un d’autre. `kv.set` lève une exception lorsque la clé est déjà revendiquée par un autre espace de travail.
## Utilisez-le : mettez en cache un appel coûteux
Un cas d'utilisation typique consiste à mettre en cache une réponse tierce lente ou soumise à des limites de débit afin que les exécutions répétées la réutilisent au lieu d'en payer le coût à chaque fois.
```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,
});
```
## Modèles et conseils
* **Espaces de noms.** Préfixez les clés pour séparer les différentes préoccupations — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
* **Expiration (TTL).** Le store n'a pas d'expiration intégrée. Stockez un horodatage dans la valeur (comme dans l’exemple de cache) et vérifiez-le à la lecture, ou effacez les clés obsolètes à partir d’une [fonction déclenchée par cron](/l/fr/developers/extend/apps/logic/logic-functions).
* **Que stocker.** Toute valeur sérialisable en JSON — nombres, chaînes de caractères, tableaux, objets. Gardez les entrées petites ; ceci sert à la coordination et à la mise en cache, pas aux blobs volumineux ni aux fichiers. Pour les fichiers, utilisez un champ `FILES` et [`uploadFile`](/l/fr/developers/extend/apps/logic/logic-functions#uploading-files).
* **Visibilité.** Les entrées résident dans la base de données de l’instance, et non comme enregistrements d’espace de travail — elles n’apparaissent jamais dans l’interface utilisateur de l’espace de travail, ne font pas partie du modèle de données de votre application et ne nécessitent aucun droit ni aucune permission d’objet.
## Alternative : un objet de stockage interrogeable
Le magasin intégré est délibérément opaque : les entrées ne sont pas des enregistrements, vous ne pouvez donc pas les parcourir dans l’interface utilisateur, les relier à d’autres objets, ni les filtrer avec des requêtes sur les enregistrements. Lorsque vous avez besoin de tout cela — par exemple un journal de synchronisation visible, ou un état par enregistrement — définissez plutôt un petit **objet technique** avec un champ `key` unique et un champ `RAW_JSON` `value`, puis interrogez-le via le [client d’API typé](/l/fr/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk). Consultez [Objets](/l/fr/developers/extend/apps/data/objects) pour la référence `defineObject` et [Données → Index uniques](/l/fr/developers/extend/apps/data/overview#unique-indexes) pour faire appliquer l’unicité des clés.
* **Délimiter la portée à un enregistrement.** Ajoutez une [relation](/l/fr/developers/extend/apps/data/relations) de l’objet de stockage vers l’objet cible plutôt que de coder l’identifiant dans la clé.
* **Visibilité et autorisations.** Les lignes résident dans la base de données de l'espace de travail comme n'importe quel autre enregistrement, elles sont donc interrogeables via l'API et respectent le [rôle](/l/fr/developers/extend/apps/config/roles) de votre application. Pour garder le store hors de l'interface principale, ne l'ajoutez pas à votre [menu de navigation](/l/fr/developers/extend/apps/layout/navigation-menu-items).
<Note>
Contrairement au magasin intégré, un objet personnalisé est toujours limité à un seul espace de travail — il ne peut pas partager des entrées entre installations comme le font les clés `SERVER`.
</Note>

View File

@@ -0,0 +1,909 @@
---
title: Fonctions logiques
description: Définissez des fonctions TypeScript côté serveur avec des déclencheurs HTTP, cron et d’événements de base de données.
icon: bolt
---
Les fonctions logiques sont des fonctions TypeScript côté serveur qui s'exécutent sur la plateforme Twenty. Elles peuvent être déclenchées par des requêtes HTTP, des programmations cron ou des événements de base de données — et peuvent également être exposées comme des outils pour des agents d'IA.
<AccordionGroup>
<Accordion title="defineLogicFunction" description="Définir des fonctions logiques et leurs déclencheurs">
Chaque fichier de fonction utilise `defineLogicFunction()` pour exporter une configuration avec un gestionnaire et des déclencheurs facultatifs.
```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 *',
},*/
});
```
Types de déclencheurs disponibles :
* **httpRoute** : Expose votre fonction sur un chemin et une méthode HTTP. Dans le code de l'application, préfixez le chemin de la route avec `/s/` lorsque vous utilisez `RestApiClient` ; l'URL déployée utilise la base injectée `TWENTY_FUNCTIONS_URL` (ou `\<server-url>/s` lorsqu'elle n'est pas définie).
<Note>
Pour appeler une fonction logique déclenchée par une route depuis un composant frontal (sans interface), consultez [Appeler une fonction logique](/l/fr/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron** : Exécute votre fonction selon une planification à l’aide d’une expression CRON.
* **databaseEvent**: S'exécute lors des événements du cycle de vie des objets de l'espace de travail. Lorsque l'opération de l'événement est `updated`, des champs spécifiques à surveiller peuvent être spécifiés dans le tableau `updatedFields`. S'il est laissé indéfini ou vide, toute mise à jour déclenchera la fonction.
> p. ex. `person.updated`, `*.created`, `company.*`
* **serverRoute** : expose une seule route HTTP à portée d’enregistrement. Une fonction de **résolution** (déclarée avec `serverRouteTriggerSettings`) s’exécute dans l’espace de travail propriétaire et renvoie soit une `Response` synchrone, soit l’espace de travail cible ET la fonction logique cible à mettre en file d’attente ; dans le cas de la mise en file d’attente, la plateforme accuse réception avec `202` et exécute cette **cible** dans la file d’attente du worker. Voir [déclencheur de route serveur](#server-route-trigger).
<Note>
Vous pouvez également exécuter manuellement une fonction à l'aide de la 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
```
Vous pouvez consulter les journaux avec :
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
#### Charge utile du déclencheur de route
Lorsqu'un déclencheur de route invoque votre fonction logique, elle reçoit un objet `RoutePayload` qui suit le
[format AWS HTTP API v2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Importez le type `RoutePayload` depuis `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' };
};
```
Le type `RoutePayload` a la structure suivante :
| Nom de la propriété | Type | Description | Exemple |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | En-têtes HTTP (uniquement ceux répertoriés dans `forwardedRequestHeaders`) | voir la section ci-dessous |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Paramètres de la chaîne de requête (plusieurs valeurs séparées par des virgules) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Paramètres de chemin extraits du modèle de route | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Corps de la requête analysé (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Corps de la requête UTF-8 d'origine, avant l'analyse JSON. Utile pour vérifier les signatures de webhook de type HMAC (par exemple `X-Hub-Signature-256` de GitHub, Stripe). `undefined` lorsque l'environnement d'exécution ne l'a pas conservé. | |
| `isBase64Encoded` | `boolean` | Indique si le corps est encodé en base64 | |
| `requestContext.http.method` | `string` | Méthode HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Chemin de la requête brut | |
#### forwardedRequestHeaders
Par défaut, les en-têtes HTTP des requêtes entrantes ne sont pas transmis à votre fonction logique pour des raisons de sécurité.
Pour accéder à des en-têtes spécifiques, listez-les dans le tableau `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'],
},
});
```
Dans votre gestionnaire, accédez aux en-têtes transférés comme ceci :
```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>
Les noms d'en-têtes sont normalisés en minuscules. Accédez-y en utilisant des clés en minuscules (p. ex., `event.headers['content-type']`).
</Note>
#### Réponse HTTP personnalisée
Par défaut, le retour d’une valeur simple depuis votre gestionnaire l’envoie en réponse `200` (JSON pour les objets, `text/plain` pour les chaînes). Pour contrôler le code d’état et les en-têtes de la réponse, retournez un objet `Response` depuis `twenty-sdk/logic-function` :
```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' },
});
};
```
Pour des raisons de sécurité, les en-têtes de réponse sont restreints à une liste d’autorisation. Tout en-tête qui ne figure pas dans la liste (par exemple `Set-Cookie`, les en-têtes CORS tels que `Access-Control-Allow-Origin`, ou les en-têtes personnalisés `X-*`) est silencieusement supprimé avant l’envoi de la réponse. Les en-têtes de réponse autorisés sont :
* `content-type`
* `content-language`
* `content-disposition`
* `cache-control`
* `retry-after`
<Note>
Le code d’état doit être un code d’état HTTP valide (compris entre 100 et 599). Les noms des en-têtes de réponse sont comparés sans tenir compte de la casse.
</Note>
#### Réponses d’erreur de la plateforme
Au-delà des réponses propres à votre gestionnaire, la plateforme répond directement aux appels de route dans certaines situations : `404` lorsque la route ou la fonction n’existe pas, `403` lorsque l’application est arrêtée, `429` lorsque la limite de débit d’exécution est atteinte, et `422` lorsque les `dependencies` de production de l’application sont trop volumineuses pour être installées — voir [limites de taille des dépendances](/l/fr/developers/extend/apps/getting-started/project-structure#dependencies).
#### Déclencheur de route serveur
`httpRouteTriggerSettings` expose une fonction sous `/s/` et résout l’espace de travail à partir de l’hôte de la requête — ce qui fonctionne lorsque chaque espace de travail a son propre domaine. Les fournisseurs tiers, en revanche, envoient les événements de chaque locataire vers **une** URL. Dans ce cas, utilisez `serverRouteTriggerSettings`.
Le déclencheur comporte deux parties :
1. Une fonction de logique de **résolution** — déclarée avec `serverRouteTriggerSettings` — s’exécute dans votre **espace de travail propriétaire** (l’espace de travail qui possède l’enregistrement de l’application). Elle inspecte la requête entrante et renvoie soit :
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — la plateforme met cette cible en file d’attente dans l’espace de travail résolu et accuse réception avec `202 { queued: true }`, ou
* une `Response` de `twenty-sdk/logic-function` — la plateforme renvoie cette réponse HTTP **de manière synchrone** et ne met **pas** de cible en file d’attente (utilisez ceci pour les échanges de vérification, comme la `url_verification` de Slack).
Le résolveur est le point d’autorisation unique — l’URL transporte uniquement l’identifiant du résolveur. **C’est l’endroit privilégié pour vérifier les signatures des requêtes** : le résolveur s’exécute avant tout effet de bord, a accès au `rawBody` original et aux en-têtes transmis, et peut rejeter la requête sans jamais toucher la cible.
2. Une fonction de logique **cible** — une fonction de logique classique par espace de travail — s’exécute ensuite dans l’espace de travail résolu avec la charge utile renvoyée par le résolveur (ou la charge utile originale de la requête si le résolveur ne l’a pas transformée). Sa valeur de retour **n’est pas** observée par l’appelant HTTP lorsque le résolveur a choisi le chemin de mise en file d’attente.
```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,
});
```
Le point de terminaison est accessible à l’adresse :
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
L’identifiant est le `universalIdentifier` du résolveur issu de votre manifeste. Enregistrez cette URL auprès du fournisseur.
**Répondre à une vérification GET.** Certains fournisseurs vérifient un point de terminaison avant d'y effectuer une livraison, en envoyant une requête `GET` contenant une vérification à la même URL vers laquelle ils effectueront ensuite des événements `POST` — l'API WhatsApp Cloud de Meta en est un exemple. Une route de serveur répond uniquement à `POST`, sauf indication contraire. Déclarez donc les deux méthodes :
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
La vérification arrive dans `event.queryStringParameters`, et le renvoi d'une `Response` la renvoie au fournisseur dans la même requête. Un corps de chaîne est envoyé en tant que `text/plain`, ce que ces fournisseurs attendent :
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods` remplace la valeur par défaut au lieu de s'y ajouter, donc `['GET']` seul fait que la route rejette `POST`. Seuls `GET` et `POST` sont pris en charge. Ne le définissez pas, sauf si le fournisseur a besoin du deuxième verbe : une route qui déclare `GET` verra son résolveur exécuté par tout appelant non authentifié, y compris les robots d'exploration et les outils de déploiement des aperçus de liens qui envoient des requêtes `GET` sans y être invités. Toute requête pour laquelle la plateforme ne dispose pas de méthode reçoit une réponse `405` sans que le résolveur ne soit jamais exécuté.
<Note>
**L’application doit être revendiquée et installée sur son espace de travail propriétaire.** Comme le résolveur s’exécute dans l’**espace de travail propriétaire** (l’espace de travail qui détient l’enregistrement de l’application), un déclencheur de route serveur ne fonctionne que lorsque l’application a été *revendiquée* — c’est‑à‑dire qu’elle possède un espace de travail propriétaire — **et** que cette application est **installée sur l’espace de travail propriétaire**. Tant que ces deux conditions ne sont pas remplies, le résolveur n’a nulle part où s’exécuter, donc la route ne peut pas être envoyée. Une application qui expose une fonction logique `serverRouteTriggerSettings` ne peut donc pas être répertoriée sur la place de marché tant qu’elle n’a pas été revendiquée et installée sur son espace de travail propriétaire.
</Note>
**Contrat du résolveur.** Le type `LogicFunctionConfig` du SDK impose cela à la compilation : dès que vous définissez `serverRouteTriggerSettings`, votre gestionnaire est contraint de retourner soit un `Response`, soit `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (ou une `Promise` de l’un ou l’autre). Sur le chemin d’acheminement, le `workspaceId` doit être celui d’un espace de travail où la fonction cible est installée, sinon la requête est rejetée avec un `404`. Un résultat qui ne correspond à aucune de ces formes — y compris un résultat dont les identifiants ne sont pas des UUID — est rejeté avec un `502`.
| Champ | Type | Notes |
| ---------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | UUID de l’espace de travail dans lequel la cible sera exécutée. |
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` de la fonction de logique à invoquer dans cet espace de travail. |
| `payload` | `object` (facultatif) | S’il est défini, il remplace le corps de la requête envoyé à la cible. |
<Warning>
**La vérification de la signature est de votre responsabilité — effectuez-la dans le résolveur.** La plateforme ne vérifie pas les signatures des requêtes. Le résolveur est l’endroit recommandé pour le faire : il s’exécute en premier, avec accès à `event.rawBody` et aux en-têtes que vous avez listés dans `forwardedRequestHeaders`, et une erreur levée (ou tout `workspaceId` ne correspondant pas) interrompt la distribution avant que la cible ne soit invoquée. Si, à la place, vous repoussez la vérification vers la cible, celle-ci doit faire attention à ne pas perdre `rawBody` et les en-têtes — c’est-à-dire que le résolveur ne doit pas retourner de `payload`. Vérifiez toujours **avant** tout effet de bord et utilisez une comparaison en temps constant.
</Warning>
Pour les signatures de requêtes, la plupart des fournisseurs signent avec HMAC-SHA256 ; les éléments qui diffèrent sont le nom de l’en-tête, l’encodage de l’empreinte et la chaîne de la charge utile signée. Quelques exemples :
| Fournisseur | En-têtes à transférer | Chaîne signée | Empreinte |
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | -------------------------------------------------------------- |
| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (le secret est en base64 après suppression de `whsec_`) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hexadécimal |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hexadécimal (préfixé par `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hexadécimal (préfixé par `v0=`) |
L’exemple de résolveur ci-dessus montre déjà le flux GitHub HMAC-SHA256 — adaptez le nom de l’en-tête, l’encodage de l’empreinte et la chaîne de la charge utile signée en fonction du fournisseur avec lequel vous vous intégrez.
<Note>
Lorsque le résolveur retourne un objet d’acheminement, la route répond `202 { queued: true }` et la cible s’exécute dans la file d’attente du worker — l’appelant n’observe jamais la latence, le résultat ou les échecs de la cible (ceux-ci sont enregistrés dans les journaux d’exécution). Cela évite que les nouvelles tentatives d’envoi de l’émetteur n’amplifient les ralentissements de traitement, ce qui est souhaitable pour l’ingestion de webhook.
Lorsque l’appelant doit lire le corps de la réponse sur la même requête (handshakes de challenge, accusés de réception interactifs), retournez plutôt un `Response` depuis le **résolveur**. La plateforme le renvoie de manière synchrone et ignore la file d’attente ; ses en-têtes passent par la même liste d’autorisation que les réponses des routes HTTP. Gardez le résolveur rapide — certains fournisseurs (par ex. Slack) ont un délai d’attente de seulement quelques secondes. Comme le résolveur est accessible en tant que point de terminaison public, protégez-le avec une limitation de débit à votre périphérie.
</Note>
#### Charge utile du déclencheur d'événement de base de données
Lorsqu’un déclencheur d’événement de base de données appelle votre fonction logique, celle-ci reçoit un `DatabaseEventPayload` par enregistrement modifié. La charge utile combine les métadonnées concernant l'espace de travail et l'objet source avec l'événement au niveau de l'enregistrement.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
La charge utile inclut :
| Propriété | Description |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `name` | Nom de l’événement, comme `person.updated`. |
| `workspaceId` | Espace de travail où l’événement s’est produit. |
| `objectMetadata` | Métadonnées pour l’objet qui a été modifié. |
| `recordId` | ID de l’enregistrement modifié. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Champs de l’acteur lorsque l’événement a été provoqué par un utilisateur de l’espace de travail. |
| `properties` | Données d’enregistrement pour l’événement, avec `before`, `after`, `diff` et `updatedFields` selon l’opération. |
| Événement | Données d’enregistrement |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
Pour les suppressions logiques (soft deletes), `.deleted` suit la structure de type mise à jour, car le champ `deletedAt` de l’enregistrement change.
Pour les suppressions permanentes, utilisez `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filtre les événements de mise à jour qui déclenchent la fonction.
`event.properties.updatedFields` indique quels champs ont réellement changé pour l’événement actuel.
</Note>
Exemple d’événement de création :
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Exemple d’événement de mise à jour :
```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,
};
};
```
Déclencher uniquement lors des mises à jour de l’adresse e-mail :
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Exemple d’événement de destruction :
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Contexte d'exécution
Chaque gestionnaire reçoit un deuxième argument décrivant l'exécution elle-même, quel que soit ce qui l'a déclenchée. Alors que la forme du premier argument varie selon le déclencheur, celle-ci ne varie pas :
```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 };
};
```
| Champ | Type | Description |
| ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | L'espace de travail auquel appartient l'exécution. |
| `userWorkspaceId` | `string \| null` | La personne qui a déclenché l'exécution, en tant que membre de cet espace de travail. |
| `workspaceMemberId` | `string \| null` | La même personne sous la forme d'un enregistrement de membre de l'espace de travail, auquel vous liez les enregistrements. |
| `retryCount` | `number` | `0` lors de la première tentative. Voir [Tâches en arrière-plan](/l/fr/developers/extend/apps/logic/background-jobs). |
| `maxRetries` | `number` | Limite supérieure des nouvelles tentatives demandées par l'application. |
`userWorkspaceId` et `workspaceMemberId` sont `null` lorsque personne n'a déclenché l'exécution : les planifications cron, les hooks d'installation et les webhooks non authentifiés n'ont aucune personne derrière eux. Ils sont également `null` lorsque la personne ne possède aucun enregistrement de membre de l'espace de travail, ou que le sien a été supprimé.
<Note>
**Le contexte vous indique qui a déclenché l'exécution.** Ce que l'exécution peut *faire* est une déclaration distincte, ci-dessous.
</Note>
#### Quels accès un appel utilise
Chaque client — `CoreApiClient`, `MetadataApiClient` et `RestApiClient` — agit en tant que personne ayant déclenché l'exécution : son rôle est combiné à celui de votre application, de sorte que l'appel ne peut jamais faire plus que l'un ou l'autre. C'est le comportement par défaut, et cela signifie qu'une personne ne peut jamais utiliser votre application pour dépasser ses propres autorisations.
Lorsque personne n'a déclenché l'exécution, il n'y a aucune personne dont il faut adopter l'identité, le même client utilise donc les propres accès de votre application : les planifications cron, les hooks d'installation et les webhooks non authentifiés ne nécessitent aucun traitement particulier.
Certains appels nécessitent légitimement les propres accès de l'application même lorsqu'une personne *est* à l'origine de l'exécution — pour lire les enregistrements de configuration de votre application, ou effectuer un travail que cette personne ne pourrait pas faire elle-même. Créez un deuxième client pour ceux-ci :
```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: { ... } });
};
```
Créez les deux une seule fois, à la portée du module, et chaque site d'appel indique alors les accès qu'il utilise par le client qu'il appelle. `RestApiClient` accepte la même option :
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**Une exécution que personne n'a déclenchée agit en tant que votre application.** Les planifications cron, les hooks d'installation et les webhooks non authentifiés n'ont aucune personne derrière eux, le client par défaut utilise donc les propres accès de votre application et continue de fonctionner. `runAs: 'application'` n'est nécessaire que lorsque vous souhaitez utiliser ces accès dans une exécution qu'une personne *a* déclenchée.
Vérifiez `context.workspaceMemberId` lorsqu'une fonction se comporte différemment selon qu'une personne est derrière elle, par exemple pour attribuer un enregistrement.
</Note>
Les assistants du SDK qui accèdent aux propres ressources de votre application utilisent toujours ses accès et ignorent `runAs` : le [magasin clé-valeur](/l/fr/developers/extend/apps/logic/key-value-store), les [connexions](/l/fr/developers/extend/apps/logic/connections), `runAgent`, `getPublicAssetUrl` et la facturation de crédits.
#### Exposer une fonction en tant qu'outil d'IA ou en tant qu'action de workflow
Les fonctions logiques peuvent être exposées sur deux surfaces, chacune avec son propre déclencheur :
* **`toolTriggerSettings`** — rend la fonction découvrable par les fonctionnalités d'IA de Twenty (chat, MCP, appel de fonctions). Utilise le schéma JSON standard, le format que les LLM comprennent nativement.
* **`workflowActionTriggerSettings`** — fait apparaître la fonction comme une étape dans le concepteur visuel de workflows. Utilise le `InputSchema` riche de Twenty afin que le concepteur puisse afficher des éditeurs de champs appropriés, des sélecteurs de variables et des libellés.
Une fonction peut opter pour l'un, l'autre ou les deux. Elles côtoient `cronTriggerSettings`, `databaseEventTriggerSettings` et `httpRouteTriggerSettings` — même modèle, même structure.
<Note>
**Lien avec l’action Code du workflow.** L’action **Code** intégrée dans le générateur de workflows est elle-même une fonction logique — Twenty en crée une pour chaque étape Code et affiche son éditeur en ligne. `workflowActionTriggerSettings` est la manière de transformer ce code ponctuel en ligne en une action **réutilisable** : définissez la fonction une fois dans votre application et elle devient sélectionnable dans n’importe quel workflow, au lieu d’être copiée-collée dans chaque étape Code. Voir l’[action Code](/l/fr/user-guide/workflows/capabilities/workflow-actions#code) dans le guide utilisateur pour la vue côté utilisateur final.
</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: {},
});
```
Points clés :
* Une fonction peut mélanger les surfaces — déclarez à la fois `toolTriggerSettings` et `workflowActionTriggerSettings` pour l'exposer à la fois dans le chat ET dans le concepteur de workflows.
* `toolTriggerSettings.inputSchema` et `workflowActionTriggerSettings.inputSchema` sont tous deux facultatifs. Lorsqu'ils sont omis, le générateur de manifeste les déduit à partir du code source du gestionnaire (schéma JSON pour l'outil d'IA, `InputSchema` de Twenty pour l'action de workflow). Fournissez-en un explicitement lorsque vous souhaitez un typage plus riche — par exemple, avec des champs compatibles avec `FieldMetadataType` comme `CURRENCY` ou `RELATION` pour le concepteur de workflows, ou avec des champs `description` que l'agent d'IA peut lire :
```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'],
},
},
});
```
Pour déclarer vos paramètres **une seule fois** et les utiliser sur les deux surfaces, définissez un seul schéma JSON (`InputJsonSchema`) et convertissez-le pour l’action de flux de travail avec `jsonSchemaToInputSchema` depuis `twenty-sdk/logic-function`. `toolTriggerSettings.inputSchema` prend directement le schéma JSON, tandis que `workflowActionTriggerSettings.inputSchema` attend le `InputSchema` de Twenty :
```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),
},
});
```
##### Un exemple complet d’action de workflow
`workflowActionTriggerSettings` accepte quatre champs :
| Champ | Objectif |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `label` | Nom affiché pour l’action dans le sélecteur d’étapes du générateur de workflows. Utilise par défaut le `name` de la fonction. |
| `icon` | Icône affichée à côté de l’action (un nom `tabler-icons`, par exemple `IconBuilding`). |
| `inputSchema` | Le riche `InputSchema` de Twenty — ce que le générateur affiche sous forme de champs configurables (avec des sélecteurs de variables). Optionnel ; déduit du gestionnaire lorsqu’il est omis. |
| `outputSchema` | Déclare la structure renvoyée par le gestionnaire, afin que **les étapes suivantes puissent établir une correspondance avec ses champs de sortie**. Optionnel ; sans cela, la sortie est exposée comme une valeur opaque unique. |
Assembler le tout — une fonction exposée comme action de workflow, avec une sortie déclarée pour que les étapes ultérieures puissent référencer `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' },
},
},
],
},
});
```
Une fois l’application installée, **Enrich Company** apparaît dans le sélecteur d’actions du générateur de workflows. Le générateur affiche `companyName` et `domain` sous forme de champs de saisie (chacun pouvant récupérer des valeurs à partir des étapes précédentes), et les étapes en aval peuvent référencer les sorties `taskId` et `enriched` de l’étape.
<Note>
**Rédigez une bonne `description`.** Les agents IA s'appuient sur le champ `description` de la fonction pour décider quand utiliser l'outil. Soyez précis sur ce que fait l'outil et quand il doit être appelé.
</Note>
</Accordion>
</AccordionGroup>
<Note>
**Aides à l’exécution.** `twenty-sdk/utils` réexporte de petites aides à l’exécution afin que les gestionnaires n’importent jamais directement depuis `twenty-shared`. Par exemple, `isDefined(value)` renvoie `false` à la fois pour `null` et `undefined` — utilisez-le pour restreindre en toute sécurité les entrées de gestionnaire optionnelles, qui peuvent arriver sous forme de `null` à l’exécution même lorsqu’elles sont typées `T | undefined` :
```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>
**Hooks d'installation** — les gestionnaires de pré-installation, de post-installation et de désinstallation — partagent ce runtime mais sont déclarés avec leurs propres fonctions define et ne prennent pas de paramètres de déclenchement. Voir [hooks d'installation](/l/fr/developers/extend/apps/config/install-hooks) pour `definePreInstallLogicFunction`, `definePostInstallLogicFunction` et `defineUninstallLogicFunction`.
</Note>
## Créer une activité de chronologie.
Utilisez `createTimelineActivity()` pour publier un événement de domaine explicite depuis une fonction logique. Définissez d'abord l'événement comme un [type d'activité de chronologie](/l/fr/developers/extend/apps/data/timeline-activity-types), puis référencez le type et les objets à l'aide de leurs identifiants universels stables :
```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 résout les identifiants universels en identifiants de métadonnées spécifiques à l'installation, valide que le type d'activité de chronologie appartient à l'application appelante et capture un instantané de ses métadonnées de présentation dans la nouvelle activité.
Les entrées requises sont `timelineActivityTypeUniversalIdentifier`, `targetObjectUniversalIdentifier` et `targetRecordId`. Vous pouvez également fournir `happensAt`, `properties` et `workspaceMemberId`. `happensAt` contrôle la position et l'heure affichée de l'événement dans la chronologie ; par défaut, il correspond à l'heure de création.
Pour associer un autre enregistrement à l'événement, fournissez ensemble `linkedRecordId` et `linkedObjectMetadataUniversalIdentifier`. Vous pouvez également fournir `linkedRecordCachedName` comme solution de repli d'affichage historique :
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
Le rôle de la fonction logique doit disposer de l'autorisation d'écriture sur l'objet standard `timelineActivity`. Gardez les types d'événements explicites non liés à une `action` ; un type lié à une action reçoit déjà des événements d'audit automatiques et produirait sinon des lignes en double.
## Clients d'API typés (twenty-client-sdk)
Le package `twenty-client-sdk` fournit deux clients GraphQL typés pour interagir avec l'API Twenty depuis vos fonctions logiques et vos composants frontaux.
| Client | Importer | Point de terminaison | Généré ? |
| ------------------- | ---------------------------- | ------------------------------------------------------------------------------ | --------------------------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — données de l'espace de travail (enregistrements, objets) | Oui, au moment du dev/build |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — configuration de l'espace de travail, téléversements de fichiers | Non, livré prêt à l'emploi |
<AccordionGroup>
<Accordion title="CoreApiClient" description="Interroger et modifier les données de l'espace de travail (enregistrements, objets)">
`CoreApiClient` est le client principal pour interroger et modifier les données de l'espace de travail. Il est **généré à partir du schéma de votre espace de travail** lors de l'exécution de `yarn twenty dev` ou `yarn twenty dev:build`, il est donc entièrement typé pour correspondre à vos objets et champs.
```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,
},
});
```
Le client utilise une syntaxe d'ensemble de sélection : passez `true` pour inclure un champ, utilisez `__args` pour les arguments et imbriquez des objets pour les relations. Vous bénéficiez d'une autocomplétion complète et d'une vérification de types basée sur le schéma de votre espace de travail.
<Note>
**CoreApiClient est généré au moment du dev/build.** Si vous l'utilisez sans exécuter d'abord `yarn twenty dev` ou `yarn twenty dev:build`, une erreur est levée. La génération se fait automatiquement — la CLI inspecte le schéma GraphQL de votre espace de travail et génère un client typé à l'aide de `@genql/cli`.
</Note>
#### Utiliser CoreSchema pour les annotations de type
`CoreSchema` fournit des types TypeScript correspondant à vos objets d'espace de travail — utile pour typer l'état des composants ou les paramètres de fonction :
```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="Configuration de l'espace de travail, applications et téléversements de fichiers">
`MetadataApiClient` est livré prêt à l'emploi avec le SDK (aucune génération requise). Il interroge le point de terminaison `/metadata` pour la configuration de l'espace de travail, les applications et les téléversements de fichiers. Il accepte la même option `runAs` que `CoreApiClient` — voir [Quels accès un appel utilise](#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 },
},
},
});
```
#### Téléverser des fichiers
Le `MetadataApiClient` inclut une méthode `uploadFile` pour joindre des fichiers aux champs de type fichier :
```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://...' }
```
| Paramètre | Type | Description |
| ---------------------------------- | -------- | ---------------------------------------------------------------- |
| `fileBuffer` | `Buffer` | Le contenu brut du fichier |
| `filename` | `string` | Le nom du fichier (utilisé pour le stockage et l'affichage) |
| `contentType` | `string` | Type MIME (par défaut `application/octet-stream` s'il est omis) |
| `fieldMetadataUniversalIdentifier` | `string` | Le `universalIdentifier` du champ de type fichier de votre objet |
Points clés :
* Utilise le `universalIdentifier` du champ (et non son ID propre à l'espace de travail), de sorte que votre code de téléversement fonctionne dans tout espace de travail où votre application est installée.
* L'`url` renvoyée est une URL signée que vous pouvez utiliser pour accéder au fichier téléversé.
</Accordion>
</AccordionGroup>
<Note>
Lorsque votre code s'exécute sur Twenty (fonctions logiques ou composants frontaux), la plateforme injecte des identifiants sous forme de variables d'environnement :
* `TWENTY_API_URL` — URL de base de l'API Twenty
* `TWENTY_APP_ACCESS_TOKEN` — Clé de courte durée pour l'accès par défaut : le rôle d'une personne combiné à celui de votre application lorsqu'une personne est à l'origine de l'exécution, le propre rôle de votre application lorsque personne ne l'est. La personne est celle qui a déclenché l'exécution dans une fonction logique, ou celle qui consulte la page dans un composant frontal.
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — Clé de courte durée limitée au seul rôle de votre application. Uniquement pour les fonctions logiques, toujours injectée dans celles-ci, et utilisée par `runAs: 'application'`.
Vous n'avez **pas** besoin de les transmettre aux clients — ils lisent automatiquement depuis `process.env`, et [Quels accès un appel utilise](#whose-access-a-call-uses) explique comment choisir entre eux. Les propres autorisations de votre application sont déterminées par le rôle déclaré avec `defineApplicationRole()` (ou référencé via `defaultRoleUniversalIdentifier` dans `application-config.ts`) ; une exécution agissant en tant que personne ne peut jamais dépasser ce rôle, ni le sien.
</Note>

View File

@@ -0,0 +1,61 @@
---
title: Vue d'ensemble
description: TypeScript côté serveur qui s’exécute dans Twenty — déclenché par des routes HTTP, des planifications CRON, des événements de base de données, des outils d’IA ou des actions de flux de travail.
icon: bolt
---
La **couche logique** d’une application Twenty est le code qui *s’exécute* — des gestionnaires TypeScript côté serveur qui réagissent aux requêtes HTTP, aux planifications CRON et aux modifications d’enregistrements ; des compétences et des agents d’IA qui résident dans l’espace de travail ; et des connexions OAuth qui permettent à vos fonctions d’agir au nom d’un utilisateur dans des services tiers.
```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) │
└────────────────────────────┘
```
## Dans cette section
<CardGroup cols={2}>
<Card title="Fonctions logiques" icon="bolt" href="/l/fr/developers/extend/apps/logic/logic-functions">
Le bloc de construction principal — types de déclencheurs, charges utiles et client API typé.
</Card>
<Card title="Compétences et agents" icon="robot" href="/l/fr/developers/extend/apps/logic/skills-and-agents">
Instructions d’agent IA réutilisables et assistants avec invites système personnalisées.
</Card>
<Card title="Connexions" icon="plug" href="/l/fr/developers/extend/apps/logic/connections">
Identifiants OAuth détenus par votre application pour des services tiers — Linear, GitHub, Slack, et plus encore.
</Card>
<Card title="Stockage clé-valeur" icon="database" href="/l/fr/developers/extend/apps/logic/key-value-store">
Conservez l’état entre les exécutions de fonctions logiques — caches, curseurs et revendications inter-espaces de travail.
</Card>
<Card title="Tâches en arrière-plan" icon="layer-group" href="/l/fr/developers/extend/apps/logic/background-jobs">
Mettez en file d’attente l’exécution d’une fonction logique sur les workers afin de contourner la limite de temps par exécution.
</Card>
</CardGroup>
## Aperçu des types de déclencheurs
Une fonction logique choisit un ou plusieurs déclencheurs — chaque entrée ci-dessous est un champ distinct sur `defineLogicFunction()`:
| Déclencheur | Moment d’exécution | Paramètre |
| -------------------------------- | ---------------------------------------------------------------------------- | ------------------------------- |
| **Route HTTP** | Une requête atteint votre point de terminaison `/s/\<path>` | `httpRouteTriggerSettings` |
| **Cron** | Une expression CRON correspond | `cronTriggerSettings` |
| **Événement de base de données** | Un enregistrement de l’espace de travail est créé, mis à jour ou supprimé | `databaseEventTriggerSettings` |
| **Outil IA** | Une fonctionnalité IA de Twenty décide d’appeler votre fonction | `toolTriggerSettings` |
| **Action de flux de travail** | Une étape de flux de travail invoque votre fonction | `workflowActionTriggerSettings` |
Les fonctions s’exécutent dans un environnement isolé dans des processus Node.js sandboxés et accèdent à l’espace de travail via un client API typé, limité au rôle déclaré sur [`defineApplication()`](/l/fr/developers/extend/apps/config/application).
<Note>
Les **hooks au moment de l’installation** — du code qui s’exécute avant ou après l’installation — partagent ce runtime mais utilisent leurs propres fonctions define et se trouvent sous [Config → Install Hooks](/l/fr/developers/extend/apps/config/install-hooks).
</Note>

View File

@@ -0,0 +1,190 @@
---
title: Compétences et agents
description: Définir les compétences et les agents d'IA pour votre application.
icon: robot
---
<Warning>
Les compétences et les agents sont actuellement en phase alpha. Cette fonctionnalité fonctionne mais est encore en évolution.
</Warning>
Les applications peuvent définir des fonctionnalités d'IA au sein de l'espace de travail — des instructions de compétences réutilisables et des agents avec des invites système personnalisées.
<AccordionGroup>
<Accordion title="defineSkill" description="Définir des compétences pour les agents IA">
Les compétences définissent des instructions et des capacités réutilisables que les agents IA peuvent utiliser dans votre espace de travail. Utilisez `defineSkill()` pour définir des compétences avec validation intégrée :
```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`,
});
```
Points clés :
* `name` est une chaîne d'identification unique pour la compétence (kebab-case recommandé).
* `label` est le nom d'affichage lisible par l'utilisateur dans l'UI.
* `content` contient les instructions de la compétence — c'est le texte que l'agent IA utilise.
* `icon` (optionnel) définit l'icône affichée dans l'UI.
* `description` (optionnel) fournit un contexte supplémentaire sur l'objectif de la compétence.
</Accordion>
<Accordion title="defineAgent" description="Définir des agents d'IA avec des prompts personnalisés">
Les agents sont des assistants IA qui vivent dans votre espace de travail. Utilisez `defineAgent()` pour créer des agents avec un prompt système personnalisé :
```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.',
});
```
Points clés :
* `name` est un identifiant unique pour l'agent (kebab-case recommandé).
* `label` est le nom d'affichage montré dans l'UI.
* `prompt` est le prompt système qui définit le comportement de l'agent.
* `description` (optionnel) fournit un contexte sur ce que fait l'agent.
* `icon` (optionnel) définit l'icône affichée dans l'UI.
* `modelId` (optionnel) remplace le modèle d'IA par défaut utilisé par l'agent.
* `responseFormat` (facultatif) contrôle la forme de la sortie de l'agent. Par défaut, la valeur est `{ type: 'text' }` pour le texte libre. Utilisez `{ type: 'json', schema }` pour forcer une sortie JSON structurée.
Par défaut, un agent renvoie du texte libre. Pour obtenir une sortie structurée, définissez `responseFormat` sur `{ type: 'json' }` et fournissez un `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,
},
},
});
```
Remarques sur le schéma :
* Le schéma est un objet plat : le `type` de chaque propriété doit être un type primitif (`string`, `number` ou `boolean`). Les objets imbriqués et les tableaux ne sont pas pris en charge.
* `description` (facultatif) sur chaque propriété guide le modèle sur ce qu'il doit y mettre.
* `required` (facultatif) répertorie les propriétés que le modèle doit toujours renvoyer.
* `additionalProperties: false` (facultatif) interdit toute propriété non déclarée dans `properties`.
</Accordion>
<Accordion title="runAgent" description="Exécuter un agent depuis une fonction logique">
`runAgent()` permet à une fonction logique d'exécuter l'un des agents de votre application (avec ses compétences et ses outils). Identifiez l'agent à l'aide du `universalIdentifier` que vous avez passé
à `defineAgent()`. Passez soit une chaîne de caractères `prompt`, soit un historique de conversation `messages`,
mais pas les deux :
```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.',
});
```
Pour les bots multi-tours (Slack, Discord, Teams, …), passez l'historique du fil de discussion en
`messages` au lieu d'un seul `prompt` :
```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?' },
],
});
```
Points clés :
* Fournissez **exactement l'un des deux** : `prompt` (string) ou `messages` (1 à 100 entrées
de `{ role: 'user' | 'assistant', content: string }`).
* L'agent s'exécute **de manière synchrone** et peut lire/mettre à jour lui-même des enregistrements via ses propres outils — `runAgent()` se résout une fois l'exécution terminée.
* Une application ne peut exécuter que ses propres agents.
* Le [rôle par défaut](/l/fr/developers/extend/apps/config/roles) de l'application doit accorder l'indicateur d'autorisation `AI` — ajoutez `SystemPermissionFlag.AI` à ses `permissionFlagUniversalIdentifiers` (ou définissez `canAccessAllTools: true`).
Sans cela, `runAgent()` échoue avec une erreur d'autorisation.
* Définissez une valeur généreuse pour `timeoutSeconds` sur la fonction logique — les exécutions d'agent peuvent prendre plusieurs secondes.
* `success` est `true` et `result` est non nul lorsque l'exécution se termine ; en cas d'échec, `success` est `false`, `result` est `null`, et `error` contient la raison (par exemple, lorsque l'espace de travail épuise ses crédits AI en cours d'exécution).
```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>
**Évitez les boucles :** si vous appelez `runAgent()` à partir d'un déclencheur d'événement de base de données `*.updated` et que l'agent met à jour le même enregistrement, limitez le déclencheur avec `updatedFields` à un champ que l'agent n'écrit jamais (par exemple l'URL source), ou vérifiez si un champ cible est encore vide avant d'appeler `runAgent()`.
</Warning>
### Exécution au nom d’un membre de l’espace de travail
Passez `runAsWorkspaceMemberId` lorsque l’exécution est déclenchée par une personne — par exemple un chatbot répondant à un message — afin que l’agent agisse comme ce membre plutôt qu’en tant qu’application :
```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',
});
```
L’exécution agit alors avec **le propre rôle du membre** : elle peut faire tout ce que ce membre peut faire et rien de plus, les enregistrements qu’elle crée lui sont attribués, et ses permissions au niveau des lignes s’appliquent. Le rôle de l’agent ne participe pas — il s’agit de la valeur par défaut de l’application pour les exécutions sans personne derrière. Omettez ce champ pour les exécutions autonomes (tâches planifiées, déclencheurs d’événements de base de données) : celles-ci conservent le rôle propre de l’agent. L’application qui agit reste rattachée au contexte de l’exécution à des fins de traçabilité, sans restreindre les permissions du membre.
Votre application est responsable de faire correspondre la personne qui a déclenché l’exécution à un membre de l’espace de travail. Nommer un membre nécessite un jeton d’accès de l’application, et ce qu’un jeton peut
nommer dépend du fait qu’il soit ou non associé à un utilisateur :
* Un jeton **sans utilisateur associé** peut nommer n’importe quel membre. Une fonction logique s’exécute
avec un tel jeton, tout comme les jetons créés via `client_credentials` ou à partir d’une clé
d’API.
* Un jeton émis **au nom d’un utilisateur**, comme en reçoit un composant frontal, ne peut
nommer que le membre de cet utilisateur lui-même.
Tout autre type — une simple session utilisateur, une clé d’API sans jeton d’application — ne peut pas
nommer de membre du tout.
<Warning>
`runAgent()` lève une exception lorsque le membre de l’espace de travail ne peut pas être résolu — un
membre inconnu ou supprimé, ou un membre sans rôle. Elle ne revient jamais au rôle
propre de l’agent, car cela accorderait plus d’accès que ce que l’appelant a demandé.
</Warning>
</Accordion>
</AccordionGroup>