Salt la conținutul principal
Aplicațiile sunt în prezent în testare alfa. Caracteristica funcționează, dar este încă în dezvoltare.
Pachetul twenty-sdk oferă blocuri de construcție tipizate pentru a crea aplicația. Această pagină acoperă toate tipurile de entități și clienții API disponibili în SDK.

Funcții DefineEntity

SDK-ul oferă funcții pentru definirea entităților aplicației. Trebuie să folosiți export default defineEntity({...}) pentru ca SDK-ul să detecteze entitățile. Aceste funcții validează configurația în timpul build-ului și oferă completare automată în IDE și siguranța tipurilor.
Organizarea fișierelor ține de dvs. Detectarea entităților este bazată pe AST — SDK-ul găsește apelurile export default defineEntity(...) indiferent unde se află fișierul. Gruparea fișierelor după tip (de exemplu, logic-functions/, roles/) este doar o convenție pentru organizarea codului, nu o cerință.
Rolurile încapsulează permisiuni asupra obiectelor și acțiunilor din spațiul dvs. de lucru.
restricted-company-role.ts
import {
  defineRole,
  PermissionFlag,
  STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';

export default defineRole({
  universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6',
  label: 'My new role',
  description: 'A role that can be used in your workspace',
  canReadAllObjectRecords: false,
  canUpdateAllObjectRecords: false,
  canSoftDeleteAllObjectRecords: false,
  canDestroyAllObjectRecords: false,
  canUpdateAllSettings: false,
  canBeAssignedToAgents: false,
  canBeAssignedToUsers: false,
  canBeAssignedToApiKeys: false,
  objectPermissions: [
    {
      objectUniversalIdentifier:
        STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
      canReadObjectRecords: true,
      canUpdateObjectRecords: true,
      canSoftDeleteObjectRecords: false,
      canDestroyObjectRecords: false,
    },
  ],
  fieldPermissions: [
    {
      objectUniversalIdentifier:
        STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
      fieldUniversalIdentifier:
        STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
      canReadFieldValue: false,
      canUpdateFieldValue: false,
    },
  ],
  permissionFlags: [PermissionFlag.APPLICATIONS],
});
Fiecare aplicație trebuie să aibă exact un apel defineApplication care descrie:
  • Identitate: identificatori, nume de afișare și descriere.
  • Permisiuni: ce rol folosesc funcțiile și componentele front-end ale acesteia.
  • (Opțional) Variabile: perechi cheie–valoare expuse funcțiilor ca variabile de mediu.
  • (Opțional) funcții de pre-instalare / post-instalare: funcții logice care rulează înainte sau după instalare.
src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';

export default defineApplication({
  universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
  displayName: 'My Twenty App',
  description: 'My first Twenty app',
  icon: 'IconWorld',
  applicationVariables: {
    DEFAULT_RECIPIENT_NAME: {
      universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
      description: 'Default recipient name for postcards',
      value: 'Jane Doe',
      isSecret: false,
    },
  },
  defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
Notițe:
  • Câmpurile universalIdentifier sunt ID-uri deterministe pe care le dețineți. Generați-le o singură dată și mențineți-le stabile între sincronizări.
  • applicationVariables devin variabile de mediu pentru funcțiile și componentele front-end (de exemplu, DEFAULT_RECIPIENT_NAME este disponibil ca process.env.DEFAULT_RECIPIENT_NAME).
  • defaultRoleUniversalIdentifier trebuie să facă referire la un rol definit cu defineRole() (vezi mai sus).
  • Funcțiile de pre-instalare și post-instalare sunt detectate automat în timpul construirii manifestului — nu trebuie să le referiți în defineApplication().

Metadate pentru marketplace

Dacă intenționați să publicați aplicația, aceste câmpuri opționale controlează modul în care apare în marketplace:
CâmpDescriere
autorNumele autorului sau al companiei
categorieCategoria aplicației pentru filtrarea în marketplace
logoUrlCalea către logo-ul aplicației (de ex., public/logo.png)
screenshotsArray de căi către capturi de ecran (de ex., public/screenshot-1.png)
aboutDescriptionDescriere markdown mai lungă pentru fila “About”. Dacă este omis, marketplace-ul folosește README.md al pachetului de pe npm
websiteUrlLink către site-ul dvs.
termsUrlLink către termenii de serviciu
emailSupportAdresă de e-mail pentru suport
issueReportUrlLink către sistemul de urmărire a problemelor

Roluri și permisiuni

Câmpul defaultRoleUniversalIdentifier din application-config.ts desemnează rolul implicit utilizat de funcțiile logice și componentele front-end ale aplicației. Consultați defineRole mai sus pentru detalii.
  • Tokenul de runtime injectat ca TWENTY_APP_ACCESS_TOKEN este derivat din acest rol.
  • Clientul tipizat este restricționat la permisiunile acordate acelui rol.
  • Respectați principiul celui mai mic privilegiu: creați un rol dedicat doar cu permisiunile de care au nevoie funcțiile.
Rol implicit pentru funcții
Când generați o aplicație nouă, CLI creează un fișier de rol implicit:
src/roles/default-role.ts
import { defineRole, PermissionFlag } from 'twenty-sdk';

export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
  'b648f87b-1d26-4961-b974-0908fd991061';

export default defineRole({
  universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
  label: 'Default function role',
  description: 'Default role for function Twenty client',
  canReadAllObjectRecords: true,
  canUpdateAllObjectRecords: false,
  canSoftDeleteAllObjectRecords: false,
  canDestroyAllObjectRecords: false,
  canUpdateAllSettings: false,
  canBeAssignedToAgents: false,
  canBeAssignedToUsers: false,
  canBeAssignedToApiKeys: false,
  objectPermissions: [],
  fieldPermissions: [],
  permissionFlags: [],
});
universalIdentifier al acestui rol este apoi referențiat în application-config.ts ca defaultRoleUniversalIdentifier.
  • *.role.ts definește ce poate face rolul.
  • application-config.ts indică acel rol, astfel încât funcțiile moștenesc permisiunile lui.
Notițe:
  • Porniți de la rolul generat, apoi restrângeți-l progresiv urmând principiul celui mai mic privilegiu.
  • Înlocuiți objectPermissions și fieldPermissions cu obiectele și câmpurile de care au nevoie efectiv funcțiile.
  • permissionFlags controlează accesul la capabilități la nivelul platformei. Mențineți-le la minimum.
  • Vedeți un exemplu funcțional: hello-world/src/roles/function-role.ts.
Obiectele personalizate descriu atât schema, cât și comportamentul înregistrărilor din spațiul dvs. de lucru. Utilizați defineObject() pentru a defini obiecte cu validare încorporată:
postCard.object.ts
import { defineObject, FieldType } from 'twenty-sdk';

enum PostCardStatus {
  DRAFT = 'DRAFT',
  SENT = 'SENT',
  DELIVERED = 'DELIVERED',
  RETURNED = 'RETURNED',
}

export default defineObject({
  universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
  nameSingular: 'postCard',
  namePlural: 'postCards',
  labelSingular: 'Post Card',
  labelPlural: 'Post Cards',
  description: 'A post card object',
  icon: 'IconMail',
  fields: [
    {
      universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
      name: 'content',
      type: FieldType.TEXT,
      label: 'Content',
      description: "Postcard's content",
      icon: 'IconAbc',
    },
    {
      universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
      name: 'recipientName',
      type: FieldType.FULL_NAME,
      label: 'Recipient name',
      icon: 'IconUser',
    },
    {
      universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
      name: 'recipientAddress',
      type: FieldType.ADDRESS,
      label: 'Recipient address',
      icon: 'IconHome',
    },
    {
      universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
      name: 'status',
      type: FieldType.SELECT,
      label: 'Status',
      icon: 'IconSend',
      defaultValue: `'${PostCardStatus.DRAFT}'`,
      options: [
        { value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
        { value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
        { value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
        { value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
      ],
    },
    {
      universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
      name: 'deliveredAt',
      type: FieldType.DATE_TIME,
      label: 'Delivered at',
      icon: 'IconCheck',
      isNullable: true,
      defaultValue: null,
    },
  ],
});
Puncte cheie:
  • Folosiți defineObject() pentru validare încorporată și suport mai bun în IDE.
  • universalIdentifier trebuie să fie unic și stabil între implementări.
  • Fiecare câmp necesită un name, un type, un label și propriul universalIdentifier stabil.
  • Matricea fields este opțională — puteți defini obiecte fără câmpuri personalizate.
  • Puteți genera obiecte noi folosind yarn twenty add, care vă ghidează prin denumire, câmpuri și relații.
Câmpurile de bază sunt create automat. Când definiți un obiect personalizat, Twenty adaugă automat câmpuri standard precum id, name, createdAt, updatedAt, createdBy, updatedBy și deletedAt. Nu trebuie să le definiți în tabloul fields — adăugați doar câmpurile personalizate proprii. Puteți suprascrie câmpurile implicite definind un câmp cu același nume în tabloul fields, dar acest lucru nu este recomandat.
Utilizați defineField() pentru a adăuga câmpuri la obiecte pe care nu le dețineți — cum ar fi obiectele standard Twenty (Person, Company etc.). sau obiecte din alte aplicații. Spre deosebire de câmpurile inline din defineObject(), câmpurile independente necesită un objectUniversalIdentifier pentru a specifica obiectul pe care îl extind:
src/fields/company-loyalty-tier.field.ts
import { defineField, FieldType } from 'twenty-sdk';

export default defineField({
  universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
  objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
  name: 'loyaltyTier',
  type: FieldType.SELECT,
  label: 'Loyalty Tier',
  icon: 'IconStar',
  options: [
    { value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
    { value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
    { value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
  ],
});
Puncte cheie:
  • objectUniversalIdentifier identifică obiectul țintă. Pentru obiectele standard, utilizați STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS exportați din twenty-sdk.
  • Atunci când definiți câmpuri inline în defineObject(), nu aveți nevoie de objectUniversalIdentifier — este moștenit de la obiectul părinte.
  • defineField() este singura modalitate de a adăuga câmpuri la obiecte pe care nu le-ați creat cu defineObject().
Relațiile conectează obiectele între ele. În Twenty, relațiile sunt întotdeauna bidirecționale — definiți ambele părți, iar fiecare parte o referențiază pe cealaltă.Există două tipuri de relații:
Tip relațieDescriereAre cheie străină?
MANY_TO_ONEMulte înregistrări ale acestui obiect indică către o singură înregistrare a ținteiDa (joinColumnName)
ONE_TO_MANYO înregistrare a acestui obiect are multe înregistrări ale ținteiNu (partea inversă)

Cum funcționează relațiile

Fiecare relație necesită două câmpuri care se referențiază reciproc:
  1. Partea MANY_TO_ONE — se află pe obiectul care deține cheia străină
  2. Partea ONE_TO_MANY — se află pe obiectul care deține colecția
Ambele câmpuri folosesc FieldType.RELATION și se referențiază încrucișat prin relationTargetFieldMetadataUniversalIdentifier.

Exemplu: Post Card are mulți destinatari

Presupuneți că un PostCard poate fi trimis către multe înregistrări PostCardRecipient. Fiecare destinatar aparține exact unui Post Card.Pasul 1: Definiți partea ONE_TO_MANY pe PostCard (partea “one”):
src/fields/post-card-recipients-on-post-card.field.ts
import { defineField, FieldType, RelationType } from 'twenty-sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';

// Export so the other side can reference it
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
// Import from the other side
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';

export default defineField({
  universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
  objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
  type: FieldType.RELATION,
  name: 'postCardRecipients',
  label: 'Post Card Recipients',
  icon: 'IconUsers',
  relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
  relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
  universalSettings: {
    relationType: RelationType.ONE_TO_MANY,
  },
});
Pasul 2: Definiți partea MANY_TO_ONE pe PostCardRecipient (partea “many” — deține cheia străină):
src/fields/post-card-on-post-card-recipient.field.ts
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';

// Export so the other side can reference it
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
// Import from the other side
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';

export default defineField({
  universalIdentifier: POST_CARD_FIELD_ID,
  objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
  type: FieldType.RELATION,
  name: 'postCard',
  label: 'Post Card',
  icon: 'IconMail',
  relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
  relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
  universalSettings: {
    relationType: RelationType.MANY_TO_ONE,
    onDelete: OnDeleteAction.CASCADE,
    joinColumnName: 'postCardId',
  },
});
Importuri circulare: Ambele câmpuri de relație se referă unul la celălalt prin universalIdentifier. Pentru a evita problemele de import circular, exportați ID-urile câmpurilor ca constante denumite din fiecare fișier și importați-le în celălalt fișier. Sistemul de build le rezolvă în timpul compilării.

Relaționarea cu obiectele standard

Pentru a crea o relație cu un obiect Twenty încorporat (Person, Company etc.), utilizați STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS:
src/fields/person-on-self-hosting-user.field.ts
import {
  defineField,
  FieldType,
  RelationType,
  OnDeleteAction,
  STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';

export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';

export default defineField({
  universalIdentifier: PERSON_FIELD_ID,
  objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
  type: FieldType.RELATION,
  name: 'person',
  label: 'Person',
  description: 'Person matching with the self hosting user',
  isNullable: true,
  relationTargetObjectMetadataUniversalIdentifier:
    STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
  relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
  universalSettings: {
    relationType: RelationType.MANY_TO_ONE,
    onDelete: OnDeleteAction.SET_NULL,
    joinColumnName: 'personId',
  },
});

Proprietăți ale câmpului de relație

ProprietateObligatoriuDescriere
tipDaTrebuie să fie FieldType.RELATION
relationTargetObjectMetadataUniversalIdentifierDauniversalIdentifier al obiectului țintă
relationTargetFieldMetadataUniversalIdentifierDauniversalIdentifier al câmpului corespunzător de pe obiectul țintă
universalSettings.relationTypeDaRelationType.MANY_TO_ONE sau RelationType.ONE_TO_MANY
universalSettings.onDeleteDoar MANY_TO_ONECe se întâmplă atunci când înregistrarea referențiată este ștearsă: CASCADE, SET_NULL, RESTRICT sau NO_ACTION
universalSettings.joinColumnNameDoar MANY_TO_ONENumele coloanei din baza de date pentru cheia străină (de ex., postCardId)

Câmpuri de relație inline în defineObject

Puteți defini, de asemenea, câmpuri de relație direct în defineObject(). În acest caz, omiteți objectUniversalIdentifier — este moștenit de la obiectul părinte:
export default defineObject({
  universalIdentifier: '...',
  nameSingular: 'postCardRecipient',
  // ...
  fields: [
    {
      universalIdentifier: POST_CARD_FIELD_ID,
      type: FieldType.RELATION,
      name: 'postCard',
      label: 'Post Card',
      relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
      relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
      universalSettings: {
        relationType: RelationType.MANY_TO_ONE,
        onDelete: OnDeleteAction.CASCADE,
        joinColumnName: 'postCardId',
      },
    },
    // ... other fields
  ],
});
Fiecare fișier de funcție folosește defineLogicFunction() pentru a exporta o configurație cu un handler și declanșatoare opționale.
src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';

const handler = async (params: RoutePayload) => {
  const client = new CoreApiClient();
  const name = 'name' in params.queryStringParameters
    ? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
    : '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: 'GET',
    isAuthRequired: false,
  },
  /*databaseEventTriggerSettings: {
    eventName: 'people.created',
  },*/
  /*cronTriggerSettings: {
    pattern: '0 0 1 1 *',
  },*/
});
Tipuri de declanșatoare disponibile:
  • httpRoute: Expune funcția pe o cale și metodă HTTP sub endpoint-ul /s/:
de ex. path: '/post-card/create' este apelabil la https://your-twenty-server.com/s/post-card/create
  • cron: Rulează funcția pe un program folosind o expresie CRON.
  • databaseEvent: Rulează la evenimentele ciclului de viață ale obiectelor din spațiul de lucru. Când operațiunea evenimentului este updated, câmpurile specifice de urmărit pot fi specificate în array-ul updatedFields. Dacă este lăsat nedefinit sau gol, orice actualizare va declanșa funcția.
de ex. person.updated, *.created, company.*
Puteți, de asemenea, să executați manual o funcție folosind CLI:
yarn twenty exec -n create-new-post-card -p '{"key": "value"}'
yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
Puteți urmări jurnalele cu:
yarn twenty logs

Payload-ul declanșatorului de rută

Când un declanșator de rută invocă funcția logică, aceasta primește un obiect RoutePayload care urmează AWS HTTP API v2 format. Importați tipul RoutePayload din twenty-sdk:
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';

const handler = async (event: RoutePayload) => {
  const { headers, queryStringParameters, pathParameters, body } = event;
  const { method, path } = event.requestContext.http;

  return { message: 'Success' };
};
Tipul RoutePayload are următoarea structură:
ProprietateTipDescriereExemplu
headersRecord<string, string | undefined>Anteturi HTTP (doar cele listate în forwardedRequestHeaders)consultați secțiunea de mai jos
queryStringParametersRecord<string, string | undefined>Parametri query string (valorile multiple unite cu virgule)/users?ids=1&ids=2&ids=3&name=Alice -> { ids: '1,2,3', name: 'Alice' }
pathParametersRecord<string, string | undefined>Parametri de cale extrași din modelul rutei/users/:id, /users/123 -> { id: '123' }
bodyobject | nullCorpul cererii analizat (JSON){ id: 1 } -> { id: 1 }
isBase64EncodedbooleanIndică dacă corpul este codificat în base64
requestContext.http.methodstringMetoda HTTP (GET, POST, PUT, PATCH, DELETE)
requestContext.http.pathstringCalea brută a cererii

forwardedRequestHeaders

În mod implicit, anteturile HTTP din cererile de intrare nu sunt transmise funcției dvs. de logică din motive de securitate. Pentru a accesa anumite anteturi, listează-le explicit în array-ul forwardedRequestHeaders:
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'],
  },
});
În handler, accesați anteturile transmise mai departe astfel:
const handler = async (event: RoutePayload) => {
  const signature = event.headers['x-webhook-signature'];
  const contentType = event.headers['content-type'];

  // Validate webhook signature...
  return { received: true };
};
Numele anteturilor sunt normalizate la litere mici. Accesați-le folosind chei cu litere mici (de exemplu, event.headers['content-type']).

Expunerea unei funcții ca instrument

Funcțiile logice pot fi expuse ca instrumente pentru agenți de IA și fluxuri de lucru. Când este marcată ca instrument, o funcție poate fi descoperită de funcționalitățile de IA ale Twenty și poate fi utilizată în automatizări ale fluxurilor de lucru.Pentru a marca o funcție logică drept instrument, setați isTool: true:
src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
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,
  isTool: true,
});
Puncte cheie:
  • Puteți combina isTool cu declanșatoare — o funcție poate fi atât un instrument (apelabilă de agenții AI), cât și declanșată de evenimente în același timp.
  • toolInputSchema (opțional): Un obiect JSON Schema care descrie parametrii pe care îi acceptă funcția dvs. Schema este calculată automat prin analiză statică a codului sursă, dar o puteți seta explicit:
export default defineLogicFunction({
  ...,
  toolInputSchema: {
    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'],
  },
});
Scrieți o description bună. Agenții AI se bazează pe câmpul description al funcției pentru a decide când să folosească instrumentul. Fiți specifici cu privire la ceea ce face instrumentul și când ar trebui apelat.
O funcție de pre-instalare este o funcție logică ce rulează automat înainte ca aplicația ta să fie instalată într-un spațiu de lucru. Aceasta este utilă pentru sarcini de validare, verificări ale condițiilor prealabile sau pregătirea stării spațiului de lucru înainte ca instalarea principală să continue.
src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';

const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
  console.log('Pre install logic function executed successfully!', payload.previousVersion);
};

export default definePreInstallLogicFunction({
  universalIdentifier: 'e0604b9e-e946-456b-886d-3f27d9a6b324',
  name: 'pre-install',
  description: 'Runs before installation to prepare the application.',
  timeoutSeconds: 300,
  handler,
});
Poți, de asemenea, să execuți manual funcția de pre-instalare oricând folosind CLI:
yarn twenty exec --preInstall
Puncte cheie:
  • Funcțiile de pre-instalare folosesc definePreInstallLogicFunction() — o variantă specializată care omite setările de declanșare (cronTriggerSettings, databaseEventTriggerSettings, httpRouteTriggerSettings, isTool).
  • Handlerul primește un InstallLogicFunctionPayload cu { previousVersion: string } — versiunea aplicației care a fost instalată anterior (sau un șir gol pentru instalări noi).
  • Este permisă o singură funcție de pre-instalare per aplicație. Construirea manifestului va genera o eroare dacă este detectată mai mult de una.
  • Proprietatea universalIdentifier a funcției este setată automat ca preInstallLogicFunctionUniversalIdentifier în manifestul aplicației în timpul build-ului — nu este nevoie să o referi în defineApplication().
  • Timpul de expirare implicit este setat la 300 de secunde (5 minute) pentru a permite sarcini de pregătire mai lungi.
O funcție post-instalare este o funcție logică care rulează automat după instalarea aplicației într-un spațiu de lucru. Aceasta este utilă pentru sarcini de configurare unice, cum ar fi popularea cu date implicite, crearea înregistrărilor inițiale sau configurarea setărilor spațiului de lucru.
src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';

const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
  console.log('Post install logic function executed successfully!', payload.previousVersion);
};

export default definePostInstallLogicFunction({
  universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
  name: 'post-install',
  description: 'Runs after installation to set up the application.',
  timeoutSeconds: 300,
  handler,
});
Poți, de asemenea, să execuți manual funcția post-instalare oricând folosind CLI:
yarn twenty exec --postInstall
Puncte cheie:
  • Funcțiile de post-instalare folosesc definePostInstallLogicFunction() — o variantă specializată care omite setările de declanșare (cronTriggerSettings, databaseEventTriggerSettings, httpRouteTriggerSettings, isTool).
  • Handlerul primește un InstallLogicFunctionPayload cu { previousVersion: string } — versiunea aplicației care a fost instalată anterior (sau un șir gol pentru instalări noi).
  • Este permisă o singură funcție de post-instalare per aplicație. Construirea manifestului va genera o eroare dacă este detectată mai mult de una.
  • Proprietatea universalIdentifier a funcției este setată automat ca postInstallLogicFunctionUniversalIdentifier în manifestul aplicației în timpul build-ului — nu este nevoie să o referi în defineApplication().
  • Timpul de expirare implicit este setat la 300 de secunde (5 minute) pentru a permite sarcini de configurare mai lungi, cum ar fi popularea datelor.
Componentele front-end sunt componente React care se afișează direct în interfața Twenty. Rulează într-un Web Worker izolat folosind Remote DOM — codul este izolat (sandboxed), dar se redă nativ în pagină, nu într-un iframe.

Unde pot fi utilizate componentele frontale

Componentele frontale pot fi afișate în două locații în cadrul Twenty:
  • Panou lateral — Componentele frontale care nu sunt headless se deschid în panoul lateral din dreapta. Acesta este comportamentul implicit atunci când o componentă frontală este declanșată din meniul de comenzi.
  • Widgeturi (tablouri de bord și pagini de înregistrare) — Componentele frontale pot fi încorporate ca widgeturi în machetele de pagină. La configurarea unui tablou de bord sau a machetei unei pagini de înregistrare, utilizatorii pot adăuga un widget de componentă frontală.

Exemplu de bază

Cel mai rapid mod de a vedea o componentă front-end în acțiune este să o înregistrați ca o comandă. Adăugarea unui câmp command cu isPinned: true o face să apară ca un buton de acțiune rapidă în colțul din dreapta sus al paginii — nu este nevoie de layout de pagină:
src/front-components/hello-world.tsx
import { defineFrontComponent } from 'twenty-sdk';

const HelloWorld = () => {
  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Hello from my app!</h1>
      <p>This component renders inside Twenty.</p>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
  name: 'hello-world',
  description: 'A simple front component',
  component: HelloWorld,
  command: {
    universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
    shortLabel: 'Hello',
    label: 'Hello World',
    icon: 'IconBolt',
    isPinned: true,
    availabilityType: 'GLOBAL',
  },
});
După sincronizarea cu yarn twenty dev (sau prin rularea o singură dată a comenzii yarn twenty dev --once), acțiunea rapidă apare în colțul din dreapta sus al paginii:
Buton de acțiune rapidă în colțul din dreapta sus
Faceți clic pe el pentru a afișa componenta inline.

Câmpuri de configurare

CâmpObligatoriuDescriere
universalIdentifierDaID unic stabil pentru această componentă
componentDaO funcție de componentă React
nameNuNume afișat
descriptionNuDescriere a ceea ce face componenta
isHeadlessNuSetați la true dacă componenta nu are UI vizibilă (vezi mai jos)
commandNuÎnregistrați componenta ca o comandă (consultați opțiunile comenzii mai jos)

Plasarea unei componente front-end pe o pagină

Dincolo de comenzi, puteți încorpora o componentă front-end direct într-o pagină de înregistrare adăugând-o ca widget într-un layout de pagină. Consultați secțiunea definePageLayout pentru detalii.

Headless vs non-headless

Componentele frontale au două moduri de randare controlate de opțiunea isHeadless:Non-headless (implicit) — Componenta afișează o interfață vizibilă. Când este declanșat din meniul de comenzi, se deschide în panoul lateral. Acesta este comportamentul implicit când isHeadless este false sau omis.Headless (isHeadless: true) — Componenta se montează invizibil în fundal. Nu deschide panoul lateral. Componentele headless sunt concepute pentru acțiuni care execută logică și apoi se demontează — de exemplu, rularea unei sarcini asincrone, navigarea la o pagină sau afișarea unui modal de confirmare. Se potrivesc în mod natural cu componentele Command din SDK descrise mai jos.
src/front-components/sync-tracker.tsx
import { defineFrontComponent, useRecordId, enqueueSnackbar } from 'twenty-sdk';
import { useEffect } from 'react';

const SyncTracker = () => {
  const recordId = useRecordId();

  useEffect(() => {
    enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' });
  }, [recordId]);

  return null;
};

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'sync-tracker',
  description: 'Tracks record views silently',
  isHeadless: true,
  component: SyncTracker,
});
Deoarece componenta returnează null, Twenty omite redarea unui container pentru ea — nu apare spațiu gol în layout. Componenta are în continuare acces la toate hook-urile și la API-ul de comunicare cu gazda.

Componentele Command din SDK

Pachetul twenty-sdk oferă patru componente ajutătoare Command, concepute pentru componente front-end headless. Fiecare componentă execută o acțiune la montare, gestionează erorile afișând o notificare snackbar și demontează automat componenta de interfață la final.Importă-le din twenty-sdk/command:
  • Command — Rulează un callback asincron prin prop-ul execute.
  • CommandLink — Navighează către o rută a aplicației. Props: to, params, queryParams, options.
  • CommandModal — Deschide un modal de confirmare. Dacă utilizatorul confirmă, execută callback-ul execute. Props: title, subtitle, execute, confirmButtonText, confirmButtonAccent.
  • CommandOpenSidePanelPage — Deschide o anumită pagină din panoul lateral. Props: page, pageTitle, pageIcon.
Iată un exemplu complet de componentă front-end headless care folosește Command pentru a rula o acțiune din meniul de comenzi:
src/front-components/run-action.tsx
import { defineFrontComponent } from 'twenty-sdk';
import { Command } from 'twenty-sdk/command';
import { CoreApiClient } from 'twenty-sdk/clients';

const RunAction = () => {
  const execute = async () => {
    const client = new CoreApiClient();

    await client.mutation({
      createTask: {
        __args: { data: { title: 'Created by my app' } },
        id: true,
      },
    });
  };

  return <Command execute={execute} />;
};

export default defineFrontComponent({
  universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
  name: 'run-action',
  description: 'Creates a task from the command menu',
  component: RunAction,
  isHeadless: true,
  command: {
    universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
    label: 'Run my action',
    icon: 'IconPlayerPlay',
  },
});
Și un exemplu care folosește CommandModal pentru a cere confirmarea înainte de execuție:
src/front-components/delete-draft.tsx
import { defineFrontComponent } from 'twenty-sdk';
import { CommandModal } from 'twenty-sdk/command';

const DeleteDraft = () => {
  const execute = async () => {
    // perform the deletion
  };

  return (
    <CommandModal
      title="Delete draft?"
      subtitle="This action cannot be undone."
      execute={execute}
      confirmButtonText="Delete"
      confirmButtonAccent="danger"
    />
  );
};

export default defineFrontComponent({
  universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
  name: 'delete-draft',
  description: 'Deletes a draft with confirmation',
  component: DeleteDraft,
  isHeadless: true,
  command: {
    universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
    label: 'Delete draft',
    icon: 'IconTrash',
  },
});

Accesarea contextului de rulare

În interiorul componentei, folosiți hook-urile SDK pentru a accesa utilizatorul curent, înregistrarea curentă și instanța componentei:
src/front-components/record-info.tsx
import {
  defineFrontComponent,
  useUserId,
  useRecordId,
  useFrontComponentId,
} from 'twenty-sdk';

const RecordInfo = () => {
  const userId = useUserId();
  const recordId = useRecordId();
  const componentId = useFrontComponentId();

  return (
    <div>
      <p>User: {userId}</p>
      <p>Record: {recordId ?? 'No record context'}</p>
      <p>Component: {componentId}</p>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012',
  name: 'record-info',
  component: RecordInfo,
});
Hook-uri disponibile:
HookReturneazăDescriere
useUserId()string sau nullID-ul utilizatorului curent
useRecordId()string sau nullID-ul înregistrării curente (când este plasată pe o pagină de înregistrare)
useFrontComponentId()stringID-ul acestei instanțe de componentă
useFrontComponentExecutionContext(selector)variazăAccesați întregul context de execuție cu o funcție selector

API-ul de comunicare cu gazda

Componentele front-end pot declanșa navigare, ferestre modale și notificări folosind funcții din twenty-sdk:
FuncțieDescriere
navigate(to, params?, queryParams?, options?)Navigați la o pagină din aplicație
openSidePanelPage(params)Deschideți un panou lateral
closeSidePanel()Închideți panoul lateral
openCommandConfirmationModal(params)Afișați un dialog de confirmare
enqueueSnackbar(params)Afișați o notificare tip toast
unmountFrontComponent()Demontați componenta
updateProgress(progress)Actualizați un indicator de progres
Iată un exemplu care folosește API-ul gazdă pentru a afișa un snackbar și a închide panoul lateral după finalizarea unei acțiuni:
src/front-components/archive-record.tsx
import { defineFrontComponent, useRecordId } from 'twenty-sdk';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/clients';

const ArchiveRecord = () => {
  const recordId = useRecordId();

  const handleArchive = async () => {
    const client = new CoreApiClient();

    await client.mutation({
      updateTask: {
        __args: { id: recordId, data: { status: 'ARCHIVED' } },
        id: true,
      },
    });

    await enqueueSnackbar({
      message: 'Record archived',
      variant: 'success',
    });

    await closeSidePanel();
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>Archive this record?</p>
      <button onClick={handleArchive}>Archive</button>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
  name: 'archive-record',
  description: 'Archives the current record',
  component: ArchiveRecord,
});

Opțiuni pentru comandă

Adăugarea unui câmp command la defineFrontComponent înregistrează componenta în meniul de comenzi (Cmd+K). Dacă isPinned este true, apare și ca buton de acțiune rapidă în colțul din dreapta sus al paginii.
CâmpObligatoriuDescriere
universalIdentifierDaID unic stabil pentru comandă
labelDaEtichetă completă afișată în meniul de comenzi (Cmd+K)
shortLabelNuEtichetă mai scurtă afișată pe butonul de acțiune rapidă fixat
iconNuNumele pictogramei afișat lângă etichetă (de ex. 'IconBolt', 'IconSend')
isPinnedNuCând este true, afișează comanda ca buton de acțiune rapidă în colțul din dreapta sus al paginii
availabilityTypeNuControlează unde apare comanda: 'GLOBAL' (mereu disponibilă), 'RECORD_SELECTION' (doar când sunt selectate înregistrări) sau 'FALLBACK' (afișată când nicio altă comandă nu se potrivește)
availabilityObjectUniversalIdentifierNuRestricționați comanda la paginile unui anumit tip de obiect (de ex., doar pe înregistrările Company)
conditionalAvailabilityExpressionNuO expresie booleană pentru a controla dinamic dacă comanda este vizibilă (vezi mai jos)

Expresii de disponibilitate condițională

Câmpul conditionalAvailabilityExpression vă permite să controlați când este vizibilă o comandă în funcție de contextul paginii curente. Importați variabile tipizate și operatori din twenty-sdk pentru a construi expresii:
import {
  defineFrontComponent,
  pageType,
  numberOfSelectedRecords,
  objectPermissions,
  everyEquals,
  isDefined,
} from 'twenty-sdk';

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'bulk-action',
  component: BulkAction,
  command: {
    universalIdentifier: '...',
    label: 'Bulk Update',
    availabilityType: 'RECORD_SELECTION',
    conditionalAvailabilityExpression: everyEquals(
      objectPermissions,
      'canUpdateObjectRecords',
      true,
    ),
  },
});
Variabile de context — acestea reprezintă starea curentă a paginii:
VariabilăTipDescriere
pageTypestringTipul paginii curente (de ex. 'RecordIndexPage', 'RecordShowPage')
isInSidePanelbooleanDacă componenta este redată într-un panou lateral
numberOfSelectedRecordsnumberNumărul de înregistrări selectate în prezent
isSelectAllbooleanDacă “select all” este activ
selectedRecordsarrayObiectele înregistrărilor selectate
favoriteRecordIdsarrayID-urile înregistrărilor marcate ca favorite
objectPermissionsobjectPermisiuni pentru tipul de obiect curent
targetObjectReadPermissionsobjectPermisiuni de citire pentru obiectul țintă
targetObjectWritePermissionsobjectPermisiuni de scriere pentru obiectul țintă
featureFlagsobjectSteaguri de caracteristici active
objectMetadataItemobjectMetadatele tipului de obiect curent
hasAnySoftDeleteFilterOnViewbooleanDacă vizualizarea curentă are un filtru soft-delete
Operatori — combinați variabilele în expresii booleene:
OperatorDescriere
isDefined(value)true dacă valoarea nu este null/undefined
isNonEmptyString(value)true dacă valoarea este un șir nevid
includes(array, value)true dacă array-ul conține valoarea
includesEvery(array, prop, value)true dacă proprietatea fiecărui element include valoarea
every(array, prop)true dacă proprietatea este truthy pentru fiecare element
everyDefined(array, prop)true dacă proprietatea este definită pentru fiecare element
everyEquals(array, prop, value)true dacă proprietatea este egală cu valoarea pentru fiecare element
some(array, prop)true dacă proprietatea este truthy pe cel puțin un element
someDefined(array, prop)true dacă proprietatea este definită pe cel puțin un element
someEquals(array, prop, value)true dacă proprietatea este egală cu valoarea pe cel puțin un element
someNonEmptyString(array, prop)true dacă proprietatea este un șir nevid pe cel puțin un element
none(array, prop)true dacă proprietatea este falsy pentru fiecare element
noneDefined(array, prop)true dacă proprietatea este nedefinită pentru fiecare element
noneEquals(array, prop, value)true dacă proprietatea nu este egală cu valoarea pe niciun element

Resurse publice

Componentele front-end pot accesa fișiere din directorul public/ al aplicației folosind getPublicAssetUrl:
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk';

const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'logo',
  component: Logo,
});
Consultați secțiunea despre resurse publice pentru detalii.

Stilizare

Componentele front-end acceptă mai multe abordări de stilizare. Puteți folosi:
  • Stiluri inlinestyle={{ color: 'red' }}
  • Componente Twenty UI — import din twenty-sdk/ui (Button, Tag, Status, Chip, Avatar și altele)
  • Emotion — CSS-in-JS cu @emotion/react
  • Styled-components — pattern-uri styled.div
  • Tailwind CSS — clase utilitare
  • Orice bibliotecă CSS-in-JS compatibilă cu React
import { defineFrontComponent } from 'twenty-sdk';
import { Button, Tag, Status } from 'twenty-sdk/ui';

const StyledWidget = () => {
  return (
    <div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
      <Button title="Click me" onClick={() => alert('Clicked!')} />
      <Tag text="Active" color="green" />
      <Status color="green" text="Online" />
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
  name: 'styled-widget',
  component: StyledWidget,
});
Abilitățile definesc instrucțiuni și capabilități reutilizabile pe care agenții AI le pot folosi în spațiul dvs. de lucru. Folosiți defineSkill() pentru a defini abilități cu validare încorporată:
src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk';

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`,
});
Puncte cheie:
  • name este un șir identificator unic pentru abilitate (se recomandă kebab-case).
  • label este numele lizibil afișat în interfața cu utilizatorul (UI).
  • content conține instrucțiunile abilității — acesta este textul pe care agentul AI îl folosește.
  • icon (opțional) setează pictograma afișată în UI.
  • description (opțional) oferă context suplimentar despre scopul abilității.
Agenții sunt asistenți AI care există în interiorul spațiului dvs. de lucru. Utilizați defineAgent() pentru a crea agenți cu un prompt de sistem personalizat:
src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';

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.',
});
Puncte cheie:
  • name este un șir identificator unic pentru agent (se recomandă kebab-case).
  • label este numele de afișare din interfața cu utilizatorul (UI).
  • prompt conține promptul de sistem — acesta este textul de instrucțiuni care definește comportamentul agentului.
  • description (opțional) oferă context suplimentar despre scopul agentului.
  • icon (opțional) setează pictograma afișată în UI.
  • modelId (opțional) suprascrie modelul AI implicit utilizat de agent.
Vizualizările sunt configurații salvate despre cum sunt afișate înregistrările unui obiect — inclusiv ce câmpuri sunt vizibile, ordinea lor și orice filtre sau grupuri aplicate. Utilizați defineView() pentru a livra vizualizări preconfigurate împreună cu aplicația:
src/views/example-view.ts
import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object';

export default defineView({
  universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
  name: 'All example items',
  objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
  icon: 'IconList',
  key: ViewKey.INDEX,
  position: 0,
  fields: [
    {
      universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
      fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
      position: 0,
      isVisible: true,
      size: 200,
    },
  ],
});
Puncte cheie:
  • objectUniversalIdentifier specifică la ce obiect se aplică această vizualizare.
  • key determină tipul vizualizării (de ex., ViewKey.INDEX pentru vizualizarea principală de listă).
  • fields controlează ce coloane apar și ordinea acestora. Fiecare câmp face referire la un fieldMetadataUniversalIdentifier.
  • Puteți defini, de asemenea, filters, filterGroups, groups și fieldGroups pentru configurații mai avansate.
  • position controlează ordonarea atunci când există mai multe vizualizări pentru același obiect.
Elementele de meniu de navigare adaugă intrări personalizate în bara laterală a spațiului de lucru. Utilizați defineNavigationMenuItem() pentru a lega la vizualizări, URL-uri externe sau obiecte:
src/navigation-menu-items/example-navigation-menu-item.ts
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view';

export default defineNavigationMenuItem({
  universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
  name: 'example-navigation-menu-item',
  icon: 'IconList',
  color: 'blue',
  position: 0,
  type: NavigationMenuItemType.VIEW,
  viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
Puncte cheie:
  • type determină la ce face trimitere elementul de meniu: NavigationMenuItemType.VIEW pentru o vizualizare salvată sau NavigationMenuItemType.LINK pentru un URL extern.
  • Pentru link-uri către vizualizări, setați viewUniversalIdentifier. Pentru link-uri externe, setați link.
  • position controlează ordonarea în bara laterală.
  • icon și color (opțional) personalizează aspectul.
Machetele de pagină vă permit să personalizați aspectul unei pagini de detalii a unei înregistrări — ce file apar, ce widgeturi sunt în fiecare filă și cum sunt aranjate. Utilizați definePageLayout() pentru a livra machete personalizate împreună cu aplicația:
src/page-layouts/example-record-page-layout.ts
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';

export default definePageLayout({
  universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
  name: 'Example Record Page',
  type: 'RECORD_PAGE',
  objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
  tabs: [
    {
      universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
      title: 'Hello World',
      position: 50,
      icon: 'IconWorld',
      layoutMode: PageLayoutTabLayoutMode.CANVAS,
      widgets: [
        {
          universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
          title: 'Hello World',
          type: 'FRONT_COMPONENT',
          configuration: {
            configurationType: 'FRONT_COMPONENT',
            frontComponentUniversalIdentifier:
              HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
          },
        },
      ],
    },
  ],
});
Puncte cheie:
  • type este de obicei 'RECORD_PAGE' pentru a personaliza vizualizarea de detaliu a unui obiect specific.
  • objectUniversalIdentifier specifică la ce obiect se aplică această machetă.
  • Fiecare tab definește o secțiune a paginii cu un title, position și layoutMode (CANVAS pentru layout liber).
  • Fiecare widget dintr-o filă poate reda o componentă frontend, o listă de relații sau alte tipuri de widgeturi integrate.
  • position pe file le controlează ordinea. Folosiți valori mai mari (de ex., 50) pentru a plasa filele personalizate după cele integrate.

Resurse publice (folderul public/)

Folderul public/ din rădăcina aplicației conține fișiere statice — imagini, pictograme, fonturi sau orice alte resurse de care are nevoie aplicația la rulare. Aceste fișiere sunt incluse automat în build-uri, sincronizate în timpul modului de dezvoltare și încărcate pe server. Fișierele plasate în public/ sunt:
  • Accesibile public — odată sincronizate pe server, resursele sunt servite la un URL public. Nu este necesară autentificarea pentru a le accesa.
  • Disponibile în componentele frontend — folosiți URL-urile resurselor pentru a afișa imagini, pictograme sau orice media în componentele React.
  • Disponibile în funcțiile logice — referiți URL-urile resurselor în e-mailuri, răspunsuri API sau orice logică pe server.
  • Utilizate pentru metadatele marketplace-ului — câmpurile logoUrl și screenshots din defineApplication() fac referire la fișiere din acest folder (de ex., public/logo.png). Acestea sunt afișate în marketplace când aplicația este publicată.
  • Sincronizate automat în modul de dezvoltare — când adăugați, actualizați sau ștergeți un fișier în public/, acesta este sincronizat automat cu serverul. Nu este nevoie de repornire.
  • Incluse în build-uriyarn twenty build împachetează toate resursele publice în outputul de distribuție.

Accesarea resurselor publice cu getPublicAssetUrl

Utilizați helperul getPublicAssetUrl din twenty-sdk pentru a obține URL-ul complet al unui fișier din directorul public/. Funcționează atât în funcții logice, cât și în componente frontend. Într-o funcție logică:
src/logic-functions/send-invoice.ts
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk';

const handler = async (): Promise<any> => {
  const logoUrl = getPublicAssetUrl('logo.png');
  const invoiceUrl = getPublicAssetUrl('templates/invoice.png');

  // Fetch the file content (no auth required — public endpoint)
  const response = await fetch(invoiceUrl);
  const buffer = await response.arrayBuffer();

  return { logoUrl, size: buffer.byteLength };
};

export default defineLogicFunction({
  universalIdentifier: 'a1b2c3d4-...',
  name: 'send-invoice',
  description: 'Sends an invoice with the app logo',
  timeoutSeconds: 10,
  handler,
});
Într-o componentă frontend:
src/front-components/company-card.tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk';

export default defineFrontComponent(() => {
  const logoUrl = getPublicAssetUrl('logo.png');

  return <img src={logoUrl} alt="App logo" />;
});
Argumentul path este relativ la folderul public/ al aplicației. Atât getPublicAssetUrl('logo.png'), cât și getPublicAssetUrl('public/logo.png') se rezolvă la același URL — prefixul public/ este eliminat automat dacă este prezent.

Utilizarea pachetelor npm

Puteți instala și utiliza orice pachet npm în aplicația dvs. Atât funcțiile logice, cât și componentele frontend sunt împachetate cu esbuild, care integrează toate dependențele în output — nu sunt necesare node_modules la rulare.

Instalarea unui pachet

yarn add axios
Apoi importați-l în codul dvs.:
src/logic-functions/fetch-data.ts
import { defineLogicFunction } from 'twenty-sdk';
import axios from 'axios';

const handler = async (): Promise<any> => {
  const { data } = await axios.get('https://api.example.com/data');

  return { data };
};

export default defineLogicFunction({
  universalIdentifier: '...',
  name: 'fetch-data',
  description: 'Fetches data from an external API',
  timeoutSeconds: 10,
  handler,
});
Același lucru funcționează și pentru componentele frontend:
src/front-components/chart.tsx
import { defineFrontComponent } from 'twenty-sdk';
import { format } from 'date-fns';

const DateWidget = () => {
  return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
};

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'date-widget',
  component: DateWidget,
});

Cum funcționează împachetarea

Pasul de build folosește esbuild pentru a produce un singur fișier autonom pentru fiecare funcție logică și pentru fiecare componentă frontend. Toate pachetele importate sunt integrate în bundle. Funcțiile logice rulează într-un mediu Node.js. Modulele built-in Node (fs, path, crypto, http etc.) sunt disponibile și nu trebuie instalate. Componentele frontend rulează într-un Web Worker. Modulele built-in Node nu sunt disponibile — doar API-urile de browser și pachetele npm care funcționează într-un mediu de browser. Ambele medii au twenty-client-sdk/core și twenty-client-sdk/metadata disponibile ca module pre-furnizate — acestea nu sunt incluse în bundle, ci sunt rezolvate la rulare de către server.

Generarea scheletului entităților cu yarn twenty add

În loc să creați manual fișiere de entități, puteți folosi generatorul interactiv (scaffolder):
yarn twenty add
Acesta vă solicită să alegeți un tip de entitate și vă ghidează prin câmpurile necesare. Generează un fișier gata de utilizare, cu un universalIdentifier stabil și apelul corect defineEntity(). Puteți de asemenea să transmiteți direct tipul de entitate pentru a sări peste primul prompt:
yarn twenty add object
yarn twenty add logicFunction
yarn twenty add frontComponent

Tipuri de entități disponibile

Tipul entitățiiComandăFișier generat
Obiectyarn twenty add objectsrc/objects/<name>.ts
Câmpyarn twenty add fieldsrc/fields/<name>.ts
Funcție logicăyarn twenty add logicFunctionsrc/logic-functions/<name>.ts
Componentă frontendyarn twenty add frontComponentsrc/front-components/<name>.tsx
Rolyarn twenty add rolesrc/roles/<name>.ts
Abilitateyarn twenty add skillsrc/skills/<name>.ts
Agentyarn twenty add agentsrc/agents/<name>.ts
Vizualizareyarn twenty add viewsrc/views/<name>.ts
Element de meniu de navigareyarn twenty add navigationMenuItemsrc/navigation-menu-items/<name>.ts
Machetă de paginăyarn twenty add pageLayoutsrc/page-layouts/<name>.ts

Ce generează scaffolder-ul

Fiecare tip de entitate are propriul său șablon. De exemplu, yarn twenty add object solicită:
  1. Nume (singular) — de ex., invoice
  2. Nume (plural) — de ex., invoices
  3. Etichetă (singular) — completată automat din nume (de ex., Invoice)
  4. Etichetă (plural) — completată automat (de ex., Invoices)
  5. Creați o vizualizare și un element de navigare? — dacă răspundeți afirmativ, scaffolder-ul generează, de asemenea, o vizualizare corespunzătoare și un link în bara laterală pentru noul obiect.
Alte tipuri de entități au prompturi mai simple — majoritatea cer doar un nume. Tipul de entitate field este mai detaliat: solicită numele câmpului, eticheta, tipul (dintr-o listă cu toate tipurile de câmp disponibile precum TEXT, NUMBER, SELECT, RELATION etc.) și universalIdentifier al obiectului țintă.

Cale de output personalizată

Utilizați opțiunea --path pentru a plasa fișierul generat într-o locație personalizată:
yarn twenty add logicFunction --path src/custom-folder

Clienți API tipizați (twenty-client-sdk)

Pachetul twenty-client-sdk oferă doi clienți GraphQL tipați pentru a interacționa cu API-ul Twenty din funcțiile de logică și componentele Front.
ClientImportațiEndpointGenerat?
CoreApiClienttwenty-client-sdk/core/graphql — date ale spațiului de lucru (înregistrări, obiecte)Da, în timpul dev/build
MetadataApiClienttwenty-client-sdk/metadata/metadata — configurarea spațiului de lucru, încărcări de fișiereNu, este livrat preconstruit
CoreApiClient este clientul principal pentru interogarea și modificarea datelor din spațiul de lucru. Este generat din schema spațiului de lucru în timpul yarn twenty dev sau yarn twenty build, astfel încât este complet tipizat pentru a corespunde obiectelor și câmpurilor dvs.
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,
  },
});
Clientul folosește o sintaxă de tip selection-set: transmiteți true pentru a include un câmp, folosiți __args pentru argumente și imbricați obiecte pentru relații. Obțineți autocompletare și verificare a tipurilor complete, pe baza schemei spațiului dvs. de lucru.
CoreApiClient este generat în timpul dev/build. Dacă îl utilizați fără a rula mai întâi yarn twenty dev sau yarn twenty build, va arunca o eroare. Generarea are loc automat — CLI inspectează schema GraphQL a spațiului dvs. de lucru și generează un client tipizat folosind @genql/cli.

Folosirea CoreSchema pentru adnotări de tip

CoreSchema oferă tipuri TypeScript care corespund obiectelor din spațiul dvs. de lucru — utile pentru tiparea stării componentelor sau a parametrilor funcțiilor:
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);
MetadataApiClient este livrat preconstruit împreună cu SDK-ul (nu este necesară generarea). Interoghează endpointul /metadata pentru configurarea spațiului de lucru, aplicații și încărcări de fișiere.
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 },
    },
  },
});

Încărcarea fișierelor

MetadataApiClient include o metodă uploadFile pentru atașarea fișierelor la câmpuri de tip fișier:
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://...' }
ParametruTipDescriere
fileBufferBufferConținutul brut al fișierului
filenamestringNumele fișierului (folosit pentru stocare și afișare)
contentTypestringTipul MIME (implicit application/octet-stream dacă este omis)
fieldMetadataUniversalIdentifierstringuniversalIdentifier al câmpului de tip fișier de pe obiectul tău
Puncte cheie:
  • Folosește universalIdentifier al câmpului (nu ID-ul specific spațiului de lucru), astfel încât codul dvs. de încărcare funcționează în orice spațiu de lucru în care aplicația dvs. este instalată.
  • url returnat este un URL semnat pe care îl poți folosi pentru a accesa fișierul încărcat.
Când codul dvs. rulează pe Twenty (funcții de logică sau componente Front), platforma injectează acreditările ca variabile de mediu:
  • TWENTY_API_URL — URL-ul de bază al API-ului Twenty
  • TWENTY_APP_ACCESS_TOKEN — Cheie cu durată scurtă, limitată la rolul implicit de funcție al aplicației
Nu trebuie să le transmiteți clienților — aceștia citesc automat din process.env. Permisiunile cheii API sunt determinate de rolul referențiat în defaultRoleUniversalIdentifier din application-config.ts.

Testarea aplicației

SDK-ul oferă API-uri programatice care vă permit să construiți, să distribuiți, să instalați și să dezinstalați aplicația din codul de test. Combinat cu Vitest și clienții API tipizați, puteți scrie teste de integrare care verifică faptul că aplicația funcționează cap-coadă împotriva unui server Twenty real.

Configurare

Aplicația generată (scaffolded) include deja Vitest. Dacă o configurați manual, instalați dependențele:
yarn add -D vitest vite-tsconfig-paths
Creați un vitest.config.ts în rădăcina aplicației:
vitest.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [
    tsconfigPaths({
      projects: ['tsconfig.spec.json'],
      ignoreConfigErrors: true,
    }),
  ],
  test: {
    testTimeout: 120_000,
    hookTimeout: 120_000,
    include: ['src/**/*.integration-test.ts'],
    setupFiles: ['src/__tests__/setup-test.ts'],
    env: {
      TWENTY_API_URL: 'http://localhost:2020',
      TWENTY_API_KEY: 'your-api-key',
    },
  },
});
Creați un fișier de configurare care verifică faptul că serverul este accesibil înainte de rularea testelor:
src/__tests__/setup-test.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';

const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');

beforeAll(async () => {
  // Verify the server is running
  const response = await fetch(`${TWENTY_API_URL}/healthz`);

  if (!response.ok) {
    throw new Error(
      `Twenty server is not reachable at ${TWENTY_API_URL}. ` +
        'Start the server before running integration tests.',
    );
  }

  // Write a temporary config for the SDK
  fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });

  fs.writeFileSync(
    path.join(TEST_CONFIG_DIR, 'config.json'),
    JSON.stringify({
      remotes: {
        local: {
          apiUrl: process.env.TWENTY_API_URL,
          apiKey: process.env.TWENTY_API_KEY,
        },
      },
      defaultRemote: 'local',
    }, null, 2),
  );
});

API-uri SDK programatice

Subruta twenty-sdk/cli exportă funcții pe care le puteți apela direct din codul de test:
FuncțieDescriere
appBuildConstruiți aplicația și, opțional, împachetați un tarball
appDeployÎncărcați un tarball pe server
appInstallInstalați aplicația în spațiul de lucru activ
appUninstallDezinstalați aplicația din spațiul de lucru activ
Fiecare funcție returnează un obiect rezultat cu success: boolean și fie data, fie error.

Scrierea unui test de integrare

Iată un exemplu complet care construiește, distribuie și instalează aplicația, apoi verifică faptul că aceasta apare în spațiul de lucru:
src/__tests__/app-install.integration-test.ts
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

const APP_PATH = process.cwd();

describe('App installation', () => {
  beforeAll(async () => {
    const buildResult = await appBuild({
      appPath: APP_PATH,
      tarball: true,
      onProgress: (message: string) => console.log(`[build] ${message}`),
    });

    if (!buildResult.success) {
      throw new Error(`Build failed: ${buildResult.error?.message}`);
    }

    const deployResult = await appDeploy({
      tarballPath: buildResult.data.tarballPath!,
      onProgress: (message: string) => console.log(`[deploy] ${message}`),
    });

    if (!deployResult.success) {
      throw new Error(`Deploy failed: ${deployResult.error?.message}`);
    }

    const installResult = await appInstall({ appPath: APP_PATH });

    if (!installResult.success) {
      throw new Error(`Install failed: ${installResult.error?.message}`);
    }
  });

  afterAll(async () => {
    await appUninstall({ appPath: APP_PATH });
  });

  it('should find the installed app in the workspace', async () => {
    const metadataClient = new MetadataApiClient();

    const result = await metadataClient.query({
      findManyApplications: {
        id: true,
        name: true,
        universalIdentifier: true,
      },
    });

    const installedApp = result.findManyApplications.find(
      (app: { universalIdentifier: string }) =>
        app.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
    );

    expect(installedApp).toBeDefined();
  });
});

Rularea testelor

Asigurați-vă că serverul Twenty local rulează, apoi:
yarn test
Sau în modul watch în timpul dezvoltării:
yarn test:watch

Verificarea tipurilor

Puteți rula și verificarea tipurilor pe aplicație fără a rula testele:
yarn twenty typecheck
Aceasta rulează tsc --noEmit și raportează orice erori de tip.

Referință CLI

Dincolo de dev, build, add și typecheck, CLI oferă comenzi pentru executarea funcțiilor, vizualizarea jurnalelor și gestionarea instalărilor de aplicații.

Executarea funcțiilor (yarn twenty exec)

Rulați manual o funcție logică fără a o declanșa prin HTTP, cron sau eveniment de bază de date:
# Execute by function name
yarn twenty exec -n create-new-post-card

# Execute by universalIdentifier
yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf

# Pass a JSON payload
yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}'

# Execute pre-install or post-install functions
yarn twenty exec --preInstall
yarn twenty exec --postInstall

Vizualizarea jurnalelor funcțiilor (yarn twenty logs)

Transmiteți în flux jurnalele de execuție pentru funcțiile logice ale aplicației:
# Stream all function logs
yarn twenty logs

# Filter by function name
yarn twenty logs -n create-new-post-card

# Filter by universalIdentifier
yarn twenty logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
Acest lucru este diferit de yarn twenty server logs, care afișează jurnalele containerului Docker. yarn twenty logs afișează jurnalele de execuție ale funcțiilor aplicației de pe serverul Twenty.

Dezinstalarea unei aplicații (yarn twenty uninstall)

Eliminați aplicația din spațiul de lucru activ:
yarn twenty uninstall

# Skip the confirmation prompt
yarn twenty uninstall --yes

Gestionarea remote-urilor

Un „remote” este un server Twenty la care se conectează aplicația. În timpul configurării, generatorul de schelet creează automat unul pentru dvs. Puteți adăuga mai multe remote-uri sau comuta între ele oricând.
# Add a new remote (opens a browser for OAuth login)
yarn twenty remote add

# Connect to a local Twenty server (auto-detects port 2020 or 3000)
yarn twenty remote add --local

# Add a remote non-interactively (useful for CI)
yarn twenty remote add --api-url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote

# List all configured remotes
yarn twenty remote list

# Switch the active remote
yarn twenty remote switch <name>
Acreditările dvs. sunt stocate în ~/.twenty/config.json.

CI cu GitHub Actions

Scaffolderul generează un workflow GitHub Actions gata de utilizare în .github/workflows/ci.yml. Rulează automat testele de integrare la fiecare push pe main și la pull request-uri. Workflow-ul:
  1. Preia codul
  2. Pornește un server Twenty temporar folosind acțiunea twentyhq/twenty/.github/actions/spawn-twenty-docker-image
  3. Instalează dependențele cu yarn install --immutable
  4. Rulează yarn test cu TWENTY_API_URL și TWENTY_API_KEY injectate din rezultatele acțiunii
.github/workflows/ci.yml
name: CI

on:
  push:
    branches:
      - main
  pull_request: {}

env:
  TWENTY_VERSION: latest

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Spawn Twenty instance
        id: twenty
        uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
        with:
          twenty-version: ${{ env.TWENTY_VERSION }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Enable Corepack
        run: corepack enable

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --immutable

      - name: Run integration tests
        run: yarn test
        env:
          TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
          TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
Nu trebuie să configurați niciun secret — acțiunea spawn-twenty-docker-image pornește un server Twenty efemer direct în runner și oferă detaliile de conectare. Secretul GITHUB_TOKEN este furnizat automat de GitHub. Pentru a fixa o versiune Twenty specifică în loc de latest, modificați variabila de mediu TWENTY_VERSION din partea de sus a workflow-ului.