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,55 @@
---
title: APIs
icon: plug
description: APIs REST y GraphQL generadas a partir del esquema de tu espacio de trabajo.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
## APIs con esquema por tenant
No existe una referencia estática de la API para Twenty. Cada espacio de trabajo tiene su propio esquema — cuando agregas un objeto personalizado (por ejemplo, `Invoice`), inmediatamente obtiene endpoints REST y GraphQL idénticos a los objetos integrados como `Company` o `Person`. La API se genera a partir del esquema, por lo que los endpoints usan directamente los nombres de tus objetos y campos — sin identificadores opacos.
La documentación de la API específica de tu espacio de trabajo está disponible en **Configuración → APIs y Webhooks** después de crear una clave de API. Incluye un entorno de pruebas interactivo donde puedes ejecutar llamadas reales contra tus datos.
## Dos APIs
**API principal** — `/rest/` y `/graphql/`
CRUD sobre registros: Personas, Empresas, Oportunidades y tus objetos personalizados. Consultar, filtrar, recorrer relaciones.
**API de metadatos** — `/rest/metadata/` y `/metadata/`
Gestión del esquema: crear/modificar/eliminar objetos, campos y relaciones. Así es como cambias tu modelo de datos de forma programática.
Ambas están disponibles como REST y GraphQL. GraphQL añade operaciones upsert por lotes y la capacidad de recorrer relaciones en una única consulta. Los mismos datos subyacentes en ambos casos.
## URLs base
| Entorno | URL base |
| --------------- | ------------------------- |
| Nube | `https://api.twenty.com/` |
| Autoalojamiento | `https://{your-domain}/` |
## Autenticación
```
Authorization: Bearer YOUR_API_KEY
```
Crea una clave de API en **Configuración → APIs y Webhooks → + Create key**. Cópiala de inmediato — se muestra una sola vez. Las claves pueden acotarse a un rol específico en **Configuración → Members → Roles → Assignment tab** para limitar a qué pueden acceder.
<VimeoEmbed videoId="928786722" title="Creación de clave de API" />
Para el acceso basado en OAuth (aplicaciones externas que actúan en nombre de los usuarios), consulta [OAuth](/l/es/developers/extend/oauth).
## Operaciones por lotes
Tanto REST como GraphQL admiten el procesamiento por lotes de hasta 60 registros por solicitud — crear, actualizar o eliminar. GraphQL también admite upsert por lotes (crear o actualizar en una sola llamada) usando nombres en plural como `CreateCompanies`.
## Límites de tasa
| Límite | Valor |
| --------------- | -------------------------- |
| Solicitudes | 100 solicitudes por minuto |
| Tamaño del lote | 60 registros por llamada |

View File

@@ -0,0 +1,124 @@
---
title: Configuración de la aplicación
description: Declara la identidad de tu aplicación, el rol predeterminado, las variables y los metadatos del marketplace con defineApplication.
icon: rocket
---
Cada aplicación debe tener exactamente una llamada a `defineApplication`. Declara:
* **Identidad** — identificador universal, nombre para mostrar, descripción.
* **Permisos** — bajo qué rol se ejecutan sus funciones de lógica y componentes de frontend.
* **Variables** *(opcionales)* — pares clave–valor expuestos a tu código como variables de entorno.
* **Hooks de preinstalación / posinstalación / desinstalación** *(opcionales)* — consulta [Funciones de lógica](/l/es/developers/extend/apps/logic/logic-functions).
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
displayName: 'My Twenty App',
description: 'My first Twenty app',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Jane Doe',
isSecret: false,
},
},
});
```
Notas:
* Los campos `universalIdentifier` son identificadores deterministas que te pertenecen. Genéralos una vez y mantenlos estables entre sincronizaciones.
* `applicationVariables` se convierten en variables de entorno para tus funciones y componentes de frontend. En las funciones lógicas (del lado del servidor), están disponibles como `process.env.VARIABLE_NAME`. En los componentes de frontend, usa `getApplicationVariable('VARIABLE_NAME')` de `twenty-sdk/front-component`. Las variables marcadas con `isSecret: true` solo se inyectan en las funciones lógicas. Los componentes de frontend solo reciben variables no secretas.
* El rol predeterminado se detecta automáticamente a partir del archivo de rol marcado con [`defineApplicationRole()`](/l/es/developers/extend/apps/config/roles); no necesitas hacer referencia a él desde `defineApplication()`.
* Las funciones de preinstalación, posinstalación y desinstalación se detectan automáticamente durante la compilación del manifiesto; no necesitas referenciarlas en `defineApplication()`.
* Pasar `defaultRoleUniversalIdentifier` explícitamente sigue siendo compatible por motivos de retrocompatibilidad, pero está en desuso en favor de `defineApplicationRole()`.
* `serverVariables` son configuraciones y secretos con ámbito de instancia (por ejemplo, claves de API). A diferencia de `applicationVariables`, no declaran ningún valor en el manifiesto: el operador del espacio de trabajo los completa desde la configuración de la aplicación, y se inyectan en las funciones lógicas solo una vez que se han establecido.
* Ambos tipos de variable aceptan `isDeprecated: true`. Úsala para retirar una variable en lugar de eliminarla: mantener la clave declarada conserva el valor almacenado (eliminarla destruye el valor que el operador introdujo), y la variable sigue siendo inyectada, por lo que tu código puede recurrir a ella — `process.env.NEW_API_KEY ?? process.env.API_KEY`. Una variable obsoleta desaparece de la configuración de la app una vez que no tiene ningún valor y nunca cuenta para la verificación de configuración de la app, por lo que `isDeprecated` tiene prioridad sobre `isRequired`.
* Para mostrar una interfaz de configuración personalizada dentro de la pestaña **Settings** de la aplicación (en lugar de la sección predeterminada de configuración de variables), declara un componente de frontend con [`defineSettingsFrontComponent()`](/l/es/developers/extend/apps/layout/front-components#custom-settings-component) en su propio archivo. Solo se permite uno por aplicación. Las secciones gestionadas por el sistema (actualización automática, App URL, conexiones) siempre permanecen visibles.
## Tipos de variables
Tanto `applicationVariables` como `serverVariables` aceptan un `type` opcional (y, para `SELECT` / `MULTI_SELECT`, una lista de `options`). Tipos admitidos: `TEXT` (predeterminado), `BOOLEAN`, `NUMBER`, `NUMERIC`, `DATE`, `DATE_TIME`, `SELECT`, `MULTI_SELECT`, `ARRAY`, `RAW_JSON`, `RICH_TEXT`.
```ts src/application-config.ts
import { defineApplication, FieldType } from 'twenty-sdk/define';
export default defineApplication({
// ...identity, role...
applicationVariables: {
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldType.NUMBER,
value: 10,
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
],
value: 'eu',
},
},
});
```
El `type` solo afecta a la **presentación y validación**: selecciona la entrada correspondiente en la interfaz de configuración del espacio de trabajo (un interruptor, campo numérico, lista desplegable, selector de fecha, editor JSON, …) y permite que la compilación valide tu configuración (por ejemplo, `SELECT` / `MULTI_SELECT` deben declarar `options` no vacías). **No** cambia cómo el valor llega a tu código.
Los valores **siempre se inyectan como cadenas**; esto es inherente a las variables de entorno (`process.env.*` solo admite cadenas). Cuando se ejecuta tu función lógica, el ejecutor serializa cada valor según su `type` declarado al construir `process.env`, por lo que el formato de cadena es coherente independientemente de cómo se haya establecido el valor (valor predeterminado del manifiesto, interfaz de configuración o una versión anterior):
| Tipo | Cadena de `process.env` |
| ------------------------------------- | ---------------------------------------------- |
| `TEXT`, `SELECT`, `DATE`, `DATE_TIME` | el valor sin procesar (`"eu"`, `"2026-01-01"`) |
| `BOOLEAN` | `"true"` / `"false"` |
| `NUMBER`, `NUMERIC` | cadena decimal (`"10"`, `"2.5"`) |
| `MULTI_SELECT`, `ARRAY` | Array JSON (`'["email","postcard"]'`) |
| `RAW_JSON`, `RICH_TEXT` | Objeto JSON (`'{"retries":3}'`) |
Analiza la cadena para volver al tipo que esperas:
```ts
const maxCards = Number(process.env.MAX_POSTCARDS); // "10" -> 10
const enabled = process.env.ENABLE_TRACKING === 'true'; // "true" -> true
const channels = JSON.parse(process.env.ENABLED_CHANNELS ?? '[]'); // '["email"]' -> ["email"]
const config = JSON.parse(process.env.PROVIDER_CONFIG ?? '{}'); // '{"retries":3}' -> { retries: 3 }
```
Lo mismo se aplica a los componentes de interfaz que leen valores mediante `getApplicationVariable('VARIABLE_NAME')`: el valor devuelto es una cadena; analízalo según sea necesario.
## Rol de función predeterminado
El rol declarado con [`defineApplicationRole()`](/l/es/developers/extend/apps/config/roles) controla a qué pueden acceder las funciones de lógica y los componentes de interfaz de la aplicación:
* Los tokens en tiempo de ejecución inyectados en tus funciones de lógica se derivan de este rol. Una llamada que actúa como una persona se limita aún más a lo que esa persona puede hacer, por lo que nunca puede superar ninguno de los dos. Consulta [Qué acceso usa una llamada](/l/es/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses).
* El cliente de API tipado está restringido a los permisos otorgados a ese rol.
* Sigue el principio de mínimo privilegio: declara solo los permisos que necesitan tus funciones.
Cuando generas una nueva aplicación, la CLI crea un archivo de rol inicial en `src/roles/default-role.ts`. Consulta [Roles y permisos](/l/es/developers/extend/apps/config/roles) para obtener la referencia completa.
## Metadatos del Marketplace
Si planeas [publicar tu aplicación](/l/es/developers/extend/apps/operations/publishing), estos campos opcionales controlan cómo aparece en el marketplace:
| Campo | Descripción |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `author` | Nombre del autor o de la empresa |
| `category` | Categoría de la aplicación para el filtrado en el marketplace |
| `logo` | Ruta al logotipo de tu aplicación incluido en `public/` (p. ej., `public/logo.png`) |
| `galleryImages` | Arreglo de rutas de imágenes de galería incluidas en `public/` (p. ej., `public/screenshot-1.png`) |
| `aboutDescription` | Descripción en Markdown más extensa para la pestaña "Acerca de". Si se omite, el marketplace utiliza el `README.md` del paquete en npm |
| `websiteUrl` | Enlace a tu sitio web |
| `termsUrl` | Enlace a los términos del servicio |
| `emailSupport` | Dirección de correo electrónico de soporte |
| `issueReportUrl` | Enlace al rastreador de incidencias |
<Note>
`logoUrl` y `screenshots` son alias obsoletos de `logo` y `galleryImages`. Las URL absolutas externas (`http://` o `https://`) no son compatibles para estos campos: se descartan con una advertencia en tiempo de compilación. En su lugar, incluye las imágenes en la carpeta `public/` de tu aplicación.
</Note>

View File

@@ -0,0 +1,188 @@
---
title: Hooks de instalación
description: "Ejecuta lógica durante el ciclo de vida de instalación, actualización o desinstalación: siembra datos, realiza copias de seguridad de registros, valida la actualización, limpia recursos externos."
icon: wrench
---
Los hooks de instalación son funciones de lógica especiales que se ejecutan durante el ciclo de vida de la instalación, actualización o desinstalación. Comparten el mismo tiempo de ejecución del controlador que las [logic functions](/l/es/developers/extend/apps/logic/logic-functions) normales, pero se declaran con sus propias funciones de definición y están fuera del modelo de desencadenadores normal (HTTP, cron, eventos de base de datos). Los hooks de instalación reciben un `InstallPayload` (`{ previousVersion?: string; newVersion: string }` — `previousVersion` es `undefined` en una instalación nueva); el hook de desinstalación recibe un `UninstallPayload` (`{ version?: string }` — la versión que se está eliminando).
Cada aplicación puede definir **como máximo un** hook de cada tipo (preinstalación, postinstalación, desinstalación). La compilación del manifiesto genera un error si se detecta más de uno de cualquier tipo.
```
┌─────────────────────────────────────────────────────────────┐
│ install flow │
│ │
│ upload package → [pre-install] → metadata migration → │
│ generate SDK → [post-install] │
│ │
│ old schema visible new schema visible │
└─────────────────────────────────────────────────────────────┘
```
## De un vistazo
| | `definePreInstallLogicFunction` | `definePostInstallLogicFunction` |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ejecuciones | Antes de la migración de metadatos — el esquema y los datos **anteriores** siguen intactos | Después de la migración y la generación del SDK — el esquema **nuevo** está en su lugar |
| Ejecución | Siempre síncrona; bloquea la instalación | Asíncrona de forma predeterminada (en cola, 3 reintentos); modo síncrono opcional mediante `shouldRunSynchronously: true` |
| En caso de fallo | La instalación se **aborta** antes de cualquier cambio de esquema | Asíncrono: se vuelve a intentar hasta 3 veces. Síncrono: quien realiza la llamada recibe `POST_INSTALL_ERROR` (los cambios de esquema **no** se revierten) |
| Uso típico | Hacer copia de seguridad o corregir datos que una migración perdería; rechazar una actualización arriesgada lanzando una excepción | Sembrar datos predeterminados, configurar el espacio de trabajo, registrar recursos externos |
**Regla general:** usa post-install de forma predeterminada. Recurra a la pre-instalación solo cuando la propia migración sea destructiva y necesite interceptar el estado anterior antes de que desaparezca.
| Quiere... | Usar |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Sembrar datos, configurar el espacio de trabajo, registrar recursos externos | `post-install` |
| Trabajo de larga duración que no debería bloquear la respuesta de instalación | `post-install` (modo asíncrono predeterminado, con reintentos del worker) |
| Configuración rápida de la que el cliente depende inmediatamente después de que finaliza la instalación | `post-install` con `shouldRunSynchronously: true` |
| Leer o hacer copia de seguridad de datos que la próxima migración perdería | `pre-install` |
| Rechazar una actualización que corrompería datos existentes | `pre-install` (lanzar desde el controlador) |
| Reconciliación en cada actualización | Cualquiera de los hooks con `shouldRunOnVersionUpgrade: true` |
## Comportamiento compartido por ambos hooks
* La configuración es una configuración de `defineLogicFunction` menos los ajustes de disparador, más `shouldRunOnVersionUpgrade`.
* **Cuándo se ejecuta**: solo en instalaciones nuevas, de forma predeterminada. Configura `shouldRunOnVersionUpgrade: true` para que también se ejecute en las actualizaciones. Usa `previousVersion` / `newVersion` para ramificar según la ruta de actualización.
* **La idempotencia es importante**: el post-install asíncrono puede reintentarse y cualquiera de los hooks se vuelve a ejecutar en las actualizaciones cuando `shouldRunOnVersionUpgrade` está activado.
* El entorno habitual de las logic functions (`APPLICATION_ID`, `APP_ACCESS_TOKEN`, `API_URL`) se inyecta, por lo que puedes llamar a la API de Twenty con el token de tu app.
* El hook se adjunta automáticamente al manifiesto de la aplicación en tiempo de compilación (`preInstallLogicFunction` / `postInstallLogicFunction`) — no hay nada que referenciar en [`defineApplication()`](/l/es/developers/extend/apps/config/application).
* El `timeoutSeconds` predeterminado es 300 para permitir tareas de configuración más largas como la siembra de datos.
* **No se ejecuta en modo de desarrollo**: `yarn twenty dev` omite el flujo de instalación y sincroniza los archivos directamente, por lo que los hooks nunca se ejecutan ahí. En su lugar, dispáralos manualmente:
```bash filename="Terminal"
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
```
<AccordionGroup>
<Accordion title="definePostInstallLogicFunction" description="Se ejecuta después de que se aplique la migración de metadatos del espacio de trabajo">
Se ejecuta una vez que tu app ha terminado de instalarse: metadatos sincronizados, cliente SDK generado, nuevo esquema disponible para consulta. Ejemplo — sembrar un registro predeterminado en instalaciones nuevas:
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
if (previousVersion) return; // fresh installs only
const client = new CoreApiClient();
await client.mutation({
createPostCard: {
__args: { data: { name: 'Welcome to Postcard', content: 'Your first card!' } },
id: true,
},
});
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
name: 'post-install',
description: 'Seeds a welcome post card after install.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: false,
shouldRunSynchronously: false,
handler,
});
```
El flag `shouldRunSynchronously` controla el modelo de ejecución:
* `false` *(predeterminado)* — encolado en la cola de mensajes (`retryLimit: 3`) y ejecutado por un worker. La respuesta de instalación se devuelve tan pronto como el trabajo se pone en la cola. **Usar para trabajo de larga duración** — siembra de grandes conjuntos de datos, APIs de terceros lentas.
* `true` — se ejecuta en línea durante el flujo de instalación. La solicitud de instalación se bloquea hasta que el handler finaliza; un error lanzado aparece como `POST_INSTALL_ERROR` para quien realiza la llamada (sin reintentos). **Usar para trabajo rápido que debe completarse antes de la respuesta.** La migración ya se ha aplicado en este punto, por lo que un fallo no revierte los cambios de esquema — solo expone el error.
</Accordion>
<Accordion title="definePreInstallLogicFunction" description="Se ejecuta antes de que se aplique la migración de metadatos del espacio de trabajo">
Se ejecuta antes de la migración de metadatos, contra el esquema **anterior** — el lugar adecuado para hacer una copia de seguridad de los datos que una migración perdería o para rechazar una actualización arriesgada. Antes de ejecutarse, el servidor realiza una "sincronización simplificada" puramente aditiva que registra solo la función de pre-instalación de la versión nueva; todo lo demás — los objetos, campos y datos de la versión anterior — permanece sin cambios cuando se ejecuta tu handler.
La pre-instalación siempre es **síncrona** y bloquea la instalación. Si el handler lanza una excepción, la instalación se aborta antes de cualquier cambio de esquema — el espacio de trabajo permanece en la versión anterior en un estado consistente. Esto es intencional: la pre-instalación es su última oportunidad para rechazar una actualización arriesgada.
Ejemplo — copiar los valores de un campo heredado antes de que la migración lo elimine:
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise<void> => {
// Only the 1.x → 2.x upgrade drops the legacy `notes` field.
if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) {
return;
}
const client = new CoreApiClient();
const { postCards } = await client.query({
postCards: {
__args: { filter: { notes: { isNot: null } } },
edges: { node: { id: true, notes: true } },
},
});
// Copy legacy `notes` into `description` before the migration drops the
// column. If this fails, the upgrade aborts and the workspace stays on v1.
for (const { node } of postCards.edges) {
await client.mutation({
updatePostCard: {
__args: { id: node.id, data: { description: node.notes } },
id: true,
},
});
}
};
export default definePreInstallLogicFunction({
universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
name: 'pre-install',
description: 'Backs up legacy notes into description before the v2 migration.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: true,
handler,
});
```
</Accordion>
</AccordionGroup>
## Hook de desinstalación
`defineUninstallLogicFunction` declara un hook que se ejecuta cuando un usuario desinstala tu aplicación. Se ejecuta **antes** de que se eliminen los metadatos, los datos y el código de la aplicación — una vez que se ejecuta la migración de eliminación ya no queda nada que ejecutar — por lo que tu controlador todavía puede consultar los objetos y registros de la aplicación. Úsalo para limpiar recursos externos: cancelar el aprovisionamiento de recursos de API, eliminar bots restantes, revocar webhooks.
Notas:
* El hook se ejecuta con el mejor esfuerzo posible: se ejecuta de forma sincrónica, pero si falla se registra el error y **nunca bloquea la desinstalación**; la limpieza no debe hacer que una aplicación sea imposible de eliminar.
* Recibe `UninstallPayload` (`{ version?: string }` — la versión que se está eliminando).
* **No** se ejecuta cuando se revierte una instalación nueva fallida: la aplicación nunca terminó de instalarse.
* El hook no puede ejecutarse después de que la aplicación haya desaparecido, por lo que la limpieza externa que depende de datos de la aplicación (por ejemplo, IDs de bots almacenados en registros) debe realizarse aquí, no en una tarea programada externa.
* Al igual que los hooks de instalación, **no se ejecuta en modo de desarrollo**; en su lugar, actívalo manualmente:
```bash filename="Terminal"
yarn twenty dev:function:exec --uninstall
```
```ts src/logic-functions/uninstall.ts
import { defineUninstallLogicFunction, type UninstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: UninstallPayload): Promise<void> => {
const client = new CoreApiClient();
const { meetingBots } = await client.query({
meetingBots: { edges: { node: { id: true, externalBotId: true } } },
});
// Delete the provider-side bots so nothing keeps recording after uninstall.
for (const { node } of meetingBots.edges) {
await fetch(`https://api.recorder.example/bots/${node.externalBotId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.RECORDER_API_KEY}` },
});
}
};
export default defineUninstallLogicFunction({
universalIdentifier: 'b2c3d4e5-6789-01bc-def0-234567890abc',
name: 'uninstall',
description: 'Deletes remaining recorder bots when the app is uninstalled.',
timeoutSeconds: 300,
handler,
});
```

View File

@@ -0,0 +1,56 @@
---
title: Resumen
description: "Configura la propia app: su identidad, los permisos predeterminados y lo que se ejecuta en el momento de la instalación."
icon: screwdriver-wrench
---
La **capa de configuración** de una app de Twenty es lo que describe la app *a la plataforma*: su identidad, los permisos que posee y el código que se ejecuta durante la instalación o la actualización. Estas declaraciones no añaden nuevas estructuras de datos ni comportamiento en tiempo de ejecución; le indican a Twenty *quién es la app* y *cómo configurarla*.
```text
┌────────────────────────────────────────────────────────┐
│ Application — identity, default role, variables, │
│ marketplace metadata │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Role — what the app's logic functions can read │ │
│ │ and write (referenced by Application) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
│
▼ (at install / upgrade time)
┌──────────────────────────────────┐
│ Pre-install hook │ before metadata migration
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Post-install hook │ after metadata migration
└──────────────────────────────────┘
│
▼ (at uninstall time)
┌──────────────────────────────────┐
│ Uninstall hook │ before app removal
└──────────────────────────────────┘
```
## En esta sección
<CardGroup cols={2}>
<Card title="Configuración de la aplicación" icon="rocket" href="/l/es/developers/extend/apps/config/application">
`defineApplication`: identidad, rol predeterminado, variables y metadatos del marketplace.
</Card>
<Card title="Roles y permisos" icon="shield-halved" href="/l/es/developers/extend/apps/config/roles">
`defineRole`: declara qué pueden leer y escribir las funciones lógicas de tu app.
</Card>
<Card title="Hooks de instalación" icon="wrench" href="/l/es/developers/extend/apps/config/install-hooks">
`definePreInstallLogicFunction`, `definePostInstallLogicFunction` y `defineUninstallLogicFunction`: hacen copias de seguridad de los datos, cargan valores predeterminados, validan actualizaciones y limpian durante la desinstalación.
</Card>
</CardGroup>
## Cómo se relacionan las piezas
* **Application** es el punto de entrada. Cada app tiene exactamente una llamada a `defineApplication()`, y apunta a un **Role** como su valor predeterminado.
* El **Role** controla qué pueden leer y escribir las funciones lógicas y los componentes de interfaz de la app. Sigue el principio de privilegios mínimos: concede solo los permisos que tu código realmente necesita.
* Los **hooks de instalación** se ejecutan durante la instalación o la actualización: el hook de preinstalación antes de la migración de metadatos (para poder rechazar una actualización arriesgada) y el hook de postinstalación después de la migración (para poder cargar datos predeterminados con el nuevo esquema). El hook de desinstalación se ejecuta justo antes de que se elimine la aplicación, por lo que puede limpiar recursos externos mientras los datos de la aplicación siguen siendo legibles.
<Note>
Los hooks de instalación comparten el entorno de ejecución de la [función lógica](/l/es/developers/extend/apps/logic/logic-functions): misma firma del handler, mismas variables de entorno, mismo cliente de API tipado, pero se declaran con sus propias funciones de definición y viven fuera del modelo de triggers habitual (HTTP, cron, eventos de base de datos).
</Note>

View File

@@ -0,0 +1,67 @@
---
title: Recursos públicos
description: Distribuye archivos estáticos — imágenes, íconos, fuentes — junto con tu aplicación mediante la carpeta `public/`.
icon: folder-open
---
La carpeta `public/` en la raíz de tu aplicación contiene archivos estáticos: imágenes, íconos, fuentes o cualquier otro recurso que tu aplicación necesite en tiempo de ejecución. Estos archivos se incluyen automáticamente en las compilaciones, se sincronizan durante el modo de desarrollo y se suben al servidor.
Los archivos ubicados en `public/` son:
* **De acceso público** — una vez sincronizados con el servidor, los recursos se sirven en una URL pública. No se necesita autenticación para acceder a ellos.
* **Disponibles en componentes de frontend** — usa las URLs de los recursos para mostrar imágenes, íconos o cualquier medio dentro de tus componentes de React.
* **Disponibles en funciones de lógica** — referencia las URLs de los recursos en correos electrónicos, respuestas de API o cualquier lógica del lado del servidor.
* **Usados para metadatos del marketplace** — los campos `logo` y `galleryImages` en `defineApplication()` referencian archivos de esta carpeta (p. ej., `public/logo.png`). Estos se muestran en el marketplace cuando se publica tu aplicación. Las URL absolutas externas se omiten para estos campos; en su lugar, incluye las imágenes en `public/`. Cada imagen no debe superar los 10 MB.
* **Sincronizados automáticamente en modo de desarrollo** — cuando agregas, actualizas o eliminas un archivo en `public/`, se sincroniza automáticamente con el servidor. No se necesita reiniciar.
* **Incluidos en las compilaciones** — `yarn twenty dev:build` agrupa todos los recursos públicos en la salida de distribución.
## Acceder a recursos públicos con `getPublicAssetUrl`
Usa el helper `getPublicAssetUrl` de `twenty-sdk` para obtener la URL completa de un archivo en tu directorio `public/`. Funciona tanto en **funciones de lógica** como en **componentes de frontend**.
**En una función de lógica:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
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,
});
```
**En un componente de frontend:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
El argumento `path` es relativo a la carpeta `public/` de tu aplicación. Tanto `getPublicAssetUrl('logo.png')` como `getPublicAssetUrl('public/logo.png')` resuelven a la misma URL — el prefijo `public/` se elimina automáticamente si está presente.

View File

@@ -0,0 +1,201 @@
---
title: Roles y permisos
description: Declara qué objetos y campos pueden leer y escribir las funciones de lógica y los componentes de interfaz de tu aplicación.
icon: shield-halved
---
Un **rol** es un conjunto de permisos: qué objetos puede leer o escribir una aplicación, qué campos puede ver y qué capacidades a nivel de plataforma puede usar. Las funciones lógicas y los componentes de interfaz de cada aplicación heredan los permisos del rol marcado con `defineApplicationRole()` (consulta [El rol de función predeterminado](#the-default-function-role) más abajo).
```ts src/roles/restricted-company-role.ts
import {
defineRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
} from 'twenty-sdk/define';
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,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
```
## Seguridad a nivel de fila
Los permisos de objetos y campos determinan *a qué objetos y campos* puede acceder o modificar un rol. **Los predicados de permisos a nivel de fila** van más allá y deciden *qué registros* puede ver y sobre los que puede actuar un rol; por ejemplo, un rol de autoservicio en el que cada usuario externo ve solo sus propios registros.
Declara los predicados con `rowLevelPermissionPredicates` en el rol. Como el resto del manifiesto, cada predicado tiene su propio `universalIdentifier` y hace referencia a un objeto y a un campo mediante su `universalIdentifier`, a un `operand` y, de forma opcional, a un campo `workspaceMember` cuyo valor se inyecta en tiempo de consulta, de modo que puedas expresar: "la relación de propietario del registro **es** el miembro actual del espacio de trabajo":
```ts src/roles/partner-role.ts
import {
defineRole,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/account-owner.field';
export default defineRole({
universalIdentifier: 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343',
label: 'Partner',
description: 'External partner — sees only its own records',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd0f0c1a2-3b4c-4d5e-8f60-111111111111',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier: ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier,
},
],
});
```
Debido a que los predicados se envían en el manifiesto, se crean, actualizan y eliminan junto con el rol en cada instalación y actualización; no hay ningún paso posterior a la instalación independiente que mantener sincronizado.
### Combinar predicados con grupos
De forma predeterminada, los predicados de un rol se combinan con `AND`. Para combinar algunos de ellos con `OR` (o para anidar lógica), declara una entrada `rowLevelPermissionPredicateGroups` y haz que cada predicado apunte a ella mediante `predicateGroupUniversalIdentifier`. Este rol permite que un socio vea una Oportunidad de la que **es propietario** o de la que es el **punto de contacto**:
```ts src/roles/partner-opportunities-role.ts
import {
defineRole,
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
const OPPORTUNITY = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity;
const CURRENT_MEMBER =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier;
export default defineRole({
universalIdentifier: 'b2a1c0d9-8e7f-4a6b-9c5d-222222222222',
label: 'Partner (opportunities)',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd4c3b2a1-0e9f-4c8b-9e7d-444444444444',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY.fields.owner.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
{
universalIdentifier: 'e5d4c3b2-1f0e-4d9c-8f8e-555555555555',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier:
OPPORTUNITY.fields.pointOfContact.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
],
});
```
Notas:
* Asigna a cada predicado y grupo un `universalIdentifier` estable (cualquier uuid): sirve como clave de la entidad a través de las actualizaciones, y los predicados hacen referencia a los grupos mediante él.
* Los predicados pueden hacer referencia a objetos y campos que sean propiedad de tu aplicación o de los objetos estándar de Twenty.
* La seguridad a nivel de fila se aplica para los espacios de trabajo en planes que la incluyen; los predicados siguen sincronizándose en otros planes, simplemente no se aplican.
## El rol de función predeterminado
Cuando generas una nueva aplicación, la CLI crea un archivo de rol predeterminado declarado con `defineApplicationRole()`:
```ts src/roles/default-role.ts
import { defineApplicationRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineApplicationRole({
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: [],
permissionFlagUniversalIdentifiers: [],
});
```
`defineApplicationRole()` es una envoltura ligera alrededor de `defineRole()` que marca **el** rol utilizado como predeterminado de tu aplicación en el momento de la instalación. La validación es idéntica a `defineRole`, pero la canalización de compilación conecta automáticamente su `universalIdentifier` con `defaultRoleUniversalIdentifier` del manifiesto de la aplicación, por lo que no necesitas hacer referencia a él desde [`defineApplication`](/l/es/developers/extend/apps/config/application).
Notas:
* Se permite exactamente **una** llamada a `defineApplicationRole(...)` por aplicación; la compilación del manifiesto fallará si encuentra más de una.
* Usa `defineRole()` (no `defineApplicationRole()`) para cualquier rol **adicional** que distribuya tu aplicación.
* Configurar `defaultRoleUniversalIdentifier` explícitamente en `defineApplication()` sigue siendo compatible por motivos de retrocompatibilidad, pero está en desuso en favor de `defineApplicationRole()`.
## Mejores prácticas
* Parte del rol generado automáticamente y luego restríngeelo progresivamente; el valor predeterminado concede un acceso amplio de lectura, lo cual rara vez es lo que quieres en producción.
* Reemplaza `objectPermissions` y `fieldPermissions` con los objetos y campos que realmente necesitan tus funciones.
* `permissionFlagUniversalIdentifiers` controla el acceso a capacidades a nivel de plataforma. Manténlos al mínimo.
* Consulta un ejemplo funcional: [`examples/hello-world/src/roles/default-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/hello-world/src/roles/default-role.ts).

View File

@@ -0,0 +1,52 @@
---
title: Extender objetos
description: Añade campos a los objetos estándar de Twenty (Person, Company, …) o a objetos de otras aplicaciones usando defineField.
icon: wand-magic-sparkles
---
Usa `defineField()` para añadir un campo a un objeto que no te pertenece — un objeto estándar de Twenty como Person o Company, u otro objeto proporcionado por otra aplicación instalada. A diferencia de los campos en línea declarados dentro de [`defineObject`](/l/es/developers/extend/apps/data/objects), los campos independientes requieren un `objectUniversalIdentifier` para especificar qué objeto extienden.
```ts src/fields/company-loyalty-tier.field.ts
import { defineField, FieldType } from 'twenty-sdk/define';
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' },
],
});
```
## Puntos clave
* `objectUniversalIdentifier` identifica el objeto de destino. Para los objetos estándar de Twenty, importa la constante desde `twenty-sdk`:
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier
// …
```
* Al definir campos **en línea dentro de `defineObject()`**, **no** necesitas `objectUniversalIdentifier` — se hereda del objeto padre.
* `defineField()` es la única forma de añadir campos a objetos que no creaste con `defineObject()`.
* La ubicación del archivo depende de ti. La convención es `src/fields/\<name>.field.ts`, pero el SDK detecta campos en cualquier lugar de `src/`.
* La `writability` también se puede configurar por campo: `MetadataWritability.APPLICATION` restringe las escrituras a las funciones lógicas de tu propia app, `MetadataWritability.SYSTEM` está reservado para los datos gestionados por la plataforma. La capacidad de escritura de un campo solo puede ser más estricta que el nivel de su objeto, y las lecturas no se ven afectadas. Consulta [Objetos](/l/es/developers/extend/apps/data/objects) para ver la semántica completa.
* Para agregar una pestaña a un diseño de página estándar (por ejemplo, la página de detalles de la Tarea o de la Empresa), usa [`definePageLayoutTab`](/l/es/developers/extend/apps/layout/page-layouts#definepagelayouttab) con `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` de `twenty-sdk/define`.
## Añadir una relación a un objeto existente
Para añadir un campo de relación (por ejemplo, vinculando tu objeto personalizado a un `Person` estándar), usa `defineField()` con `FieldType.RELATION`. El patrón es el mismo que para las relaciones en línea, pero con `objectUniversalIdentifier` establecido explícitamente. Consulta [Relaciones](/l/es/developers/extend/apps/data/relations) para conocer el patrón bidireccional.

View File

@@ -0,0 +1,143 @@
---
title: Objetos
description: Declara nuevos tipos de registro (tablas personalizadas con sus propios campos) usando defineObject.
icon: table
---
Los **objetos** personalizados son nuevos tipos de registro que tu aplicación añade a un espacio de trabajo — Tarjeta postal, Factura, Suscripción, cualquier cosa específica de tu dominio. Cada objeto declara su esquema (campos, relaciones, valores predeterminados) y un identificador universal estable que se mantiene a través de sincronizaciones e implementaciones.
```ts src/objects/post-card.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';
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,
},
],
});
```
## Puntos clave
* El `universalIdentifier` debe ser único y estable entre implementaciones.
* Cada campo requiere `name`, `type`, `label` y su propio `universalIdentifier` estable.
* La matriz `fields` es opcional: puedes definir objetos sin campos personalizados.
* `openRecordIn` establece dónde se abren los registros de este objeto al hacer clic: `ObjectOpenRecordIn.USER_CHOICE` (el valor predeterminado, que sigue la preferencia de cada miembro del espacio de trabajo en Settings → Experience), `ObjectOpenRecordIn.SIDE_PANEL` o `ObjectOpenRecordIn.RECORD_PAGE`. Fíjalo en `RECORD_PAGE` para los registros que necesitan una página completa para ser utilizables, como ocurre con los flujos de trabajo y los paneles, o en `SIDE_PANEL` para los registros que solo tienen sentido como un panel rápido, como los eventos del calendario.
* `writability` controla quién puede escribir registros del objeto en general, antes de que se apliquen los permisos por rol: `MetadataWritability.OPEN` (predeterminado — lo deciden los roles del espacio de trabajo), `MetadataWritability.APPLICATION` (solo las funciones de lógica de tu aplicación pueden crear, actualizar o eliminar registros; usa esto para objetos de configuración cuyos registros definen el comportamiento, de modo que los miembros del espacio de trabajo con amplio acceso a registros no puedan editarlos a través de la API) o `MetadataWritability.SYSTEM` (reservado para datos gestionados por la plataforma). Las lecturas no se ven afectadas: esto se aplica en el lado del servidor, a diferencia de `isUIEditable`, que solo oculta las funcionalidades de la interfaz de usuario. También existe por campo, donde solo puede ser más restrictivo que el nivel del objeto.
* Los campos en línea definidos aquí **no** necesitan un `objectUniversalIdentifier`, ya que se hereda del objeto padre. Usa [`defineField()`](/l/es/developers/extend/apps/data/extending-objects) para añadir campos a objetos que no te pertenecen.
* Puedes generar nuevos objetos con `yarn twenty dev:add object`, que te guía en la asignación de nombres, los campos y las relaciones. Consulta [Arquitectura → Generación de entidades](/l/es/developers/extend/apps/getting-started/scaffolding).
<Note>
**Los campos base se añaden automáticamente.** Cuando defines un objeto personalizado, Twenty crea campos estándar como `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` y `deletedAt` por ti. No necesitas declararlos en tu matriz `fields`, solo tus campos personalizados. Puedes sobrescribir un campo predeterminado declarando uno con el mismo nombre, pero esto rara vez es una buena idea.
</Note>
## Tipos de campo
El conjunto completo de valores de `FieldType`, exportados desde `twenty-sdk/define`:
| Categoría | Tipos |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Texto | `TEXT`, `RICH_TEXT`, `ARRAY` (de cadenas), `RAW_JSON` |
| Numérico | `NUMBER` (`universalSettings.dataType`: `'float'` / `'int'` / `'bigint'`), `NUMERIC` (precisión arbitraria), `RATING`, `POSITION` |
| Fechas | `DATE`, `DATE_TIME` |
| Selección | `BOOLEAN`, `SELECT`, `MULTI_SELECT` |
| Compuesto | `FULL_NAME`, `ADDRESS`, `EMAILS`, `PHONES`, `LINKS`, `CURRENCY`, `ACTOR`, `FILES` |
| Identificadores y relaciones | `UUID`, `RELATION`, `MORPH_RELATION` (ver [Relations](/l/es/developers/extend/apps/data/relations)) |
| Sistema | `TS_VECTOR` (vector de búsqueda de texto completo, gestionado por el servidor) |
Los tipos compuestos almacenan múltiples subcampos (por ejemplo, `FULL_NAME` = nombre + apellido; `CURRENCY` = `amountMicros` + `currencyCode`). `SELECT` y `MULTI_SELECT` requieren un arreglo `options` como en el ejemplo anterior.
## Valores predeterminados
Los valores predeterminados de cadenas literales deben ir entre comillas simples **dentro** de la cadena — `defaultValue: "'Draft'"`, no `defaultValue: "Draft"`. Por eso el campo `status` anterior utiliza `` `'${PostCardStatus.DRAFT}'` ``.
Las cadenas sin comillas se reservan para valores predeterminados calculados, evaluados cuando se crea un registro:
* `'uuid'` — genera un UUID (para campos `UUID`)
* `'now'` — la marca de tiempo actual (para campos `DATE_TIME`)
La misma convención se aplica a los subcampos de tipo cadena de los valores predeterminados compuestos (por ejemplo, `{ source: "'MANUAL'" }` en un campo `ACTOR`) y a los valores de `SELECT`/`MULTI_SELECT`. Un valor predeterminado de tipo cadena literal dejado sin comillas genera una advertencia cuando se compila tu aplicación.
## Nulabilidad
`isNullable` controla si un campo acepta `NULL`. Su valor predeterminado es `true` — omítelo para campos opcionales. Configura `isNullable: false` para hacer que un campo sea obligatorio a nivel de base de datos.
Los cambios en `isNullable` se aplican en cada sincronización, incluidas las sincronizaciones que actualizan un campo existente; de este modo, puedes cambiar la nulabilidad de un campo editando el manifiesto y sincronizando de nuevo.
<Note>
**Hacer que un campo existente no acepte valores nulos requiere un valor predeterminado.** Cuando cambies un campo a `isNullable: false`, también debes proporcionar un `defaultValue` no nulo. El valor predeterminado rellena cualquier fila `NULL` existente antes de que se aplique la restricción `NOT NULL`; sin él, la sincronización falla con `Default value cannot be null for non-nullable fields`. Los campos de relación y los campos `TS_VECTOR` siempre aceptan valores nulos, por lo que `isNullable` no tiene ningún efecto en ellos.
</Note>
```ts
{
universalIdentifier: 'b1a7c0de-1234-4f00-9abc-000000000000',
name: 'reference',
type: FieldType.TEXT,
label: 'Reference',
isNullable: false,
defaultValue: "'N/A'",
}
```
## ¿Qué sigue?
* **Conecta este objeto con otros**: consulta [Relaciones](/l/es/developers/extend/apps/data/relations) para el patrón de relación bidireccional.
* **Añade campos a objetos de otras aplicaciones**: consulta [Extender objetos](/l/es/developers/extend/apps/data/extending-objects) para `defineField()`.
* **Mostrar este objeto en la IU** — consulta [Elementos del menú de navegación](/l/es/developers/extend/apps/layout/navigation-menu-items) para añadir una entrada en la barra lateral; consulta [Vistas](/l/es/developers/extend/apps/layout/views) para añadir configuraciones de lista personalizadas.

View File

@@ -0,0 +1,101 @@
---
title: Resumen
description: "Da forma a los datos que tu aplicación agrega a un espacio de trabajo: objetos, campos y relaciones."
icon: database
---
La **capa de datos** de una aplicación de Twenty es el conjunto de datos que tu aplicación *agrega* a un espacio de trabajo — los nuevos tipos de registros que declara, las columnas que agrega a los objetos existentes y cómo esos registros se conectan entre sí.
```text
┌──────────────────────────────────────────────────┐
│ Object — a record type, e.g. PostCard │
│ ├─ Field (name, type, label) │
│ ├─ Field │
│ └─ Relation (link to another object) │
└──────────────────────────────────────────────────┘
│
├── lives in your app, OR
│
▼
┌──────────────────────────────────────────────────┐
│ Standard / other apps' objects │
│ └─ Field added by your app via defineField │
└──────────────────────────────────────────────────┘
```
## En esta sección
<CardGroup cols={2}>
<Card title="Objetos" icon="table" href="/l/es/developers/extend/apps/data/objects">
`defineObject` — declara nuevos tipos de registros con sus propios campos.
</Card>
<Card title="Extender objetos" icon="wand-magic-sparkles" href="/l/es/developers/extend/apps/data/extending-objects">
`defineField` — agrega campos a objetos estándar o de otras aplicaciones.
</Card>
<Card title="Relaciones" icon="diagram-project" href="/l/es/developers/extend/apps/data/relations">
Conexiones bidireccionales `MANY_TO_ONE` / `ONE_TO_MANY` entre objetos.
</Card>
<Card title="Tipos de actividad de la cronología" icon="clock-rotate-left" href="/l/es/developers/extend/apps/data/timeline-activity-types">
Defina eventos de auditoría automáticos y eventos explícitos de la aplicación para cronologías de registros.
</Card>
</CardGroup>
## Entidades de un vistazo
| Entidad | Propósito | Definido con |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| **Objeto** | Un nuevo tipo de registro personalizado (por ejemplo, PostCard, Invoice) con sus propios campos | `defineObject()` |
| **Campo** | Una columna en un objeto. Los campos independientes pueden ampliar objetos que no creaste (por ejemplo, agregar `loyaltyTier` a Company) | `defineField()` |
| **Relación** | Un vínculo bidireccional entre dos objetos: ambos lados se declaran como campos | `defineField()` con `FieldType.RELATION` |
| **Índice** | Un índice de base de datos para acelerar una consulta recurrente sobre uno de tus objetos | `defineIndex()` |
| **Tipo de actividad de la cronología** | Un evento de auditoría automático o un evento de dominio explícito mostrado en las líneas de tiempo de los registros. | `defineTimelineActivityType()` |
El SDK detecta estos mediante análisis AST en tiempo de compilación, por lo que la organización de archivos depende de ti; la convención es `src/objects/`, `src/fields/`, `src/indexes/` y `src/timeline-activity-types/`. Los UUID `universalIdentifier` estables vinculan todo a través de los despliegues.
## Índices (opcional)
Las aplicaciones pueden incluir índices junto con sus objetos para mantener rápidas las consultas recurrentes. El caso más habitual es una columna de estado o de clave externa que lees con frecuencia.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Índices únicos
`defineIndex` acepta `isUnique: true` tanto para unicidad de una sola columna como de varias columnas. Este es el elemento primitivo recomendado — `defineField({ isUnique: true })` está obsoleto y se eliminará en una versión futura.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Otras restricciones
* Las cláusulas `WHERE` parciales permanecen bajo el control del administrador: las aplicaciones no pueden declararlas.
* Cada objeto está limitado a 10 índices personalizados (los índices propios del framework no cuentan).
Ordena el arreglo `fields` de la forma en que Postgres debería usarlo: la columna más a la izquierda primero, como en una guía telefónica. Los índices no son gratuitos: cada escritura en la tabla los actualiza. Añade uno solo cuando tengas una consulta que lo necesite.
<Note>
¿Buscas **Application Config** o **Roles & Permissions**? Esos describen la propia aplicación en lugar de los datos que agrega; se encuentran en [Config](/l/es/developers/extend/apps/config/overview). ¿Buscas **Connections** (Linear, GitHub, Slack OAuth)? Estas existen para ser llamadas *desde* las funciones lógicas y se encuentran en [Logic](/l/es/developers/extend/apps/logic/connections).
</Note>

View File

@@ -0,0 +1,184 @@
---
title: Relaciones
description: Conecta objetos entre sí con relaciones bidireccionales MANY_TO_ONE / ONE_TO_MANY.
icon: diagram-project
---
Las relaciones conectan dos objetos entre sí. En Twenty, las relaciones siempre son **bidireccionales**: cada relación tiene dos lados, y cada lado se declara como un campo que hace referencia al otro.
| Tipo de relación | Descripción | ¿Tiene clave foránea? |
| ---------------- | --------------------------------------------------------------------------- | --------------------- |
| `MANY_TO_ONE` | Muchos registros de este objeto apuntan a un registro del objeto de destino | Sí (`joinColumnName`) |
| `ONE_TO_MANY` | Un registro de este objeto tiene muchos registros del objeto de destino | No (lado inverso) |
## Cómo funcionan las relaciones
Cada relación requiere **dos campos** que se referencian entre sí:
1. El lado **MANY_TO_ONE** — vive en el objeto que contiene la clave foránea.
2. El lado **ONE_TO_MANY** — vive en el objeto que es propietario de la colección.
Ambos campos usan `FieldType.RELATION` y se hacen referencia cruzada mediante `relationTargetFieldMetadataUniversalIdentifier`.
## Ejemplo: la tarjeta postal tiene muchos destinatarios
Un `PostCard` puede enviarse a muchos registros `PostCardRecipient`. Cada destinatario pertenece exactamente a una tarjeta postal.
**Paso 1: Define el lado ONE_TO_MANY en PostCard** (el lado "uno"):
```ts src/fields/post-card-recipients-on-post-card.field.ts
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
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,
},
});
```
**Paso 2: Define el lado MANY_TO_ONE en PostCardRecipient** (el lado "muchos" — contiene la clave foránea):
```ts src/fields/post-card-on-post-card-recipient.field.ts
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
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',
},
});
```
<Note>
**Importaciones circulares:** ambos campos de relación hacen referencia al `universalIdentifier` del otro. Para evitar problemas de importaciones circulares, exporta los ID de tus campos como constantes con nombre desde cada archivo e impórtalos en el otro. El sistema de compilación los resuelve en tiempo de compilación.
</Note>
## Relaciones de unión
Un objeto de unión conecta un registro de origen con uno o más registros de destino. Por ejemplo, `PostCardRecipient` puede contener tanto una relación `postCard` como una relación `recipient`. En el campo `ONE_TO_MANY` del objeto de origen, establece `junctionTargetFieldUniversalIdentifier` en el campo de relación del objeto de unión que apunta al destino:
```ts
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
relationTargetObjectMetadataUniversalIdentifier:
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldUniversalIdentifier: RECIPIENT_FIELD_ID,
},
});
```
`RECIPIENT_FIELD_ID` debe identificar una relación `MANY_TO_ONE` o morph en el objeto de unión. Indica a Twenty qué registro relacionado es el destino cuando una función como la [distribución de actividad de la cronología](/l/es/developers/extend/apps/data/timeline-activity-types#automatic-and-explicit-events) atraviesa la unión.
## Relacionar con objetos estándar
Para crear una relación con un objeto integrado de Twenty (Person, Company, etc.), usa `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
```ts src/fields/person-on-self-hosting-user.field.ts
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
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',
},
});
```
## Propiedades del campo de relación
| Propiedad | Obligatorio | Descripción |
| ---------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------ |
| `type` | Sí | Debe ser `FieldType.RELATION` |
| `relationTargetObjectMetadataUniversalIdentifier` | Sí | El `universalIdentifier` del objeto de destino |
| `relationTargetFieldMetadataUniversalIdentifier` | Sí | El `universalIdentifier` del campo correspondiente en el objeto de destino |
| `universalSettings.relationType` | Sí | `RelationType.MANY_TO_ONE` o `RelationType.ONE_TO_MANY` |
| `universalSettings.onDelete` | Solo para MANY_TO_ONE | Qué sucede cuando se elimina el registro referenciado: `CASCADE`, `SET_NULL`, `RESTRICT` o `NO_ACTION` |
| `universalSettings.joinColumnName` | Solo para MANY_TO_ONE | Nombre de la columna de base de datos para la clave foránea (p. ej., `postCardId`) |
| `universalSettings.junctionTargetFieldUniversalIdentifier` | Solo ONE_TO_MANY de unión | Campo de relación del objeto de unión que apunta al destino de la distribución |
## Campos de relación en línea
También puedes declarar una relación directamente dentro de [`defineObject`](/l/es/developers/extend/apps/data/objects). Cuando es en línea, omite `objectUniversalIdentifier` — se hereda del objeto padre:
```ts
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
],
});
```

View File

@@ -0,0 +1,215 @@
---
title: Apuntar a metadatos del sistema
description: Resuelve los identificadores universales deterministas de los metadatos de Twenty que se aprovisionan automáticamente en cada objeto, para que tu aplicación pueda hacer referencia a ellos sin codificarlos de forma rígida.
icon: gears
---
Cada objeto en Twenty incluye **metadatos de sistema** que tú nunca declaras directamente, como un conjunto de campos y una vista de lista principal con sus columnas. El servidor crea todo esto cuando se aprovisiona el objeto, y el conjunto crece a medida que Twenty lo hace.
Como no lo declaras, no hay ninguna constante `universalIdentifier` que puedas importar. En su lugar, el servidor **deriva** cada identificador de forma determinista, y `twenty-sdk` expone la misma derivación para que tu manifiesto pueda resolver el valor exacto que usa el servidor.
## Campos del sistema
Los campos escalares presentes en cada objeto, ninguno de los cuales declaras con [`defineField()`](/l/es/developers/extend/apps/data/extending-objects):
`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`
Entonces, ¿cómo haces referencia a `createdAt` como una columna en una [vista](/l/es/developers/extend/apps/layout/views)?
### Problema
Desde Twenty 2.19, el identificador universal de un campo de sistema se **deriva de forma determinista** en el servidor a partir de tres entradas: el identificador universal de la aplicación, el identificador universal del objeto y el nombre del campo. Inventar un id y ponerlo de forma fija en el código no funcionará: no coincide con nada en el servidor y la sincronización rechaza la referencia colgante:
```
Dev sync failed: viewField: INVALID_VIEW_DATA: Field metadata not found
```
### La solución
<Note>
`getFieldUniversalIdentifier` está disponible a partir de `twenty-sdk` 2.21.
</Note>
Usa `getFieldUniversalIdentifier` para resolver exactamente el mismo valor que utiliza el servidor. Toma las tres entradas y devuelve el identificador universal del campo:
```ts
import { getFieldUniversalIdentifier } from 'twenty-sdk/define';
const createdAtFieldId = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
});
```
* `applicationUniversalIdentifier` es el identificador de tu aplicación, el que pasas a [`defineApplication()`](/l/es/developers/extend/apps/config/application).
* `objectUniversalIdentifier` es el identificador del objeto al que pertenece el campo.
* `name` es el nombre del campo de sistema, uno de los valores enumerados arriba.
### Ejemplo: una columna createdAt en una vista
El caso típico es añadir una columna `createdAt` a una vista de uno de tus objetos personalizados. Resuelve el id del campo y haz referencia a él como a cualquier otro `fieldMetadataUniversalIdentifier`:
```ts src/views/example-view.ts
import {
defineView,
getFieldUniversalIdentifier,
} from 'twenty-sdk/define';
const APPLICATION_UNIVERSAL_IDENTIFIER =
'0b04e15c-27b2-4741-9046-b32e07469072';
const MY_OBJECT_UNIVERSAL_IDENTIFIER =
'c782b61c-70fd-4c88-9cd6-4e61ab8d7591';
export default defineView({
universalIdentifier: '70f10d44-144a-4da8-8c6f-3ec2422138c0',
name: 'All records',
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: '75a90bc4-d901-4df4-85e0-af29db5e0104',
fieldMetadataUniversalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
}),
position: 0,
isVisible: true,
size: 200,
},
],
});
```
El mismo id resuelto funciona en cualquier lugar donde se espere un `fieldMetadataUniversalIdentifier`: campos de vista, filtros, ordenaciones, agrupaciones y widgets de diseño de página.
<Note>
Resuelve el id, no lo pongas de forma fija en el código. Como el servidor deriva el valor a partir
del id de la aplicación, el id del objeto y el nombre del campo, llamar a
`getFieldUniversalIdentifier` mantiene tu referencia correcta incluso si esas
entradas cambian, y evita desviaciones si la derivación evoluciona en algún momento.
</Note>
### Campos de relación del sistema
<Note>
`getSystemRelationFieldUniversalIdentifier` está disponible desde `twenty-sdk`
2.23 en adelante y requiere un servidor Twenty en la versión 2.23 o posterior.
</Note>
Además de los campos escalares del sistema mencionados arriba, el servidor también aprovisiona cuatro **campos de relación del sistema** en cada objeto: `timelineActivities`, `attachments`, `noteTargets` y `taskTargets`, cada uno apuntando al objeto de relación estándar correspondiente.
Estos campos no se resuelven con `getFieldUniversalIdentifier`: su identificador se deriva **sin nombre**, a partir del objeto que aloja el campo y del objeto al que apunta el campo. De este modo, cambiar el nombre de un objeto nunca modifica los identificadores de sus campos de relación.
Use `getSystemRelationFieldUniversalIdentifier` para resolverlos:
```ts
import {
getSystemRelationFieldUniversalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
// rocket.attachments — the relation field hosted on your custom object
const rocketAttachmentsFieldId = getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
});
```
* `objectUniversalIdentifier` es el objeto que **aloja** el campo.
* `relationTargetObjectUniversalIdentifier` es el objeto al que el campo **apunta**.
La dirección se codifica mediante el orden de los argumentos. Para resolver el lado inverso (por ejemplo, `attachment.targetRocket`, el campo morph que el servidor crea en el objeto de relación estándar), intercambie ambos:
```ts
// attachment.targetRocket — the reverse morph field on Attachment
const attachmentTargetRocketFieldId =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
relationTargetObjectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
});
```
Como con los campos de sistema escalares, el id resuelto funciona en cualquier lugar donde se espere un `fieldMetadataUniversalIdentifier`.
## Vistas de sistema
<Note>
`getSystemViewUniversalIdentifier` y `getSystemViewFieldUniversalIdentifier`
están disponibles desde `twenty-sdk` 2.26 en adelante y requieren un servidor
Twenty en la versión 2.26 o posterior.
</Note>
El servidor también aprovisiona una **vista de sistema** en cada objeto: la vista de lista principal (`All {objectLabelPlural}`, con clave `ViewKey.INDEX` (derivada con `SYSTEM_VIEW_KEYS.INDEX`)), con una columna por cada campo visible. Al igual que los campos de relación de sistema, sus identificadores se derivan **sin usar nombres**, por lo que renombrar un objeto o un campo nunca los cambia.
Usa `getSystemViewUniversalIdentifier` para resolver la vista:
```ts
import {
getSystemViewUniversalIdentifier,
SYSTEM_VIEW_KEYS,
} from 'twenty-sdk/define';
const rocketIndexViewId = getSystemViewUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
viewKey: SYSTEM_VIEW_KEYS.INDEX,
});
```
* `objectMetadataApplicationUniversalIdentifier` es la aplicación propietaria del **objeto**, que es la que define el espacio de nombres de la vista.
* `objectUniversalIdentifier` es el objeto que la vista enumera.
* `viewKey` es el discriminador de vista de sistema: `SYSTEM_VIEW_KEYS.INDEX` para la vista de lista principal, `SYSTEM_VIEW_KEYS.FIELDS_WIDGET` para la vista del widget de campos de la página de registro. Sirve como clave para la derivación; únicamente `INDEX` se almacena además en la fila de la vista.
El id resuelto funciona en cualquier lugar donde se espere un `viewUniversalIdentifier`, como una entrada en la barra lateral de tipo [`NavigationMenuItemType.VIEW`](/l/es/developers/extend/apps/layout/navigation-menu-items). Para simplemente abrir la lista principal de un objeto, es preferible usar `NavigationMenuItemType.OBJECT` con `targetObjectUniversalIdentifier`: no necesita ninguna derivación.
`getSystemViewFieldUniversalIdentifier` resuelve una sola **columna** en una vista de sistema, a partir de la vista y del campo que muestra:
```ts
import { getSystemViewFieldUniversalIdentifier } from 'twenty-sdk/define';
const rocketNameColumnId = getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: rocketIndexViewId,
fieldMetadataUniversalIdentifier: ROCKET_NAME_FIELD_UNIVERSAL_IDENTIFIER,
});
```
Observa el primer argumento: una columna está en el espacio de nombres de la aplicación propietaria del **campo que muestra**, no de la que es propietaria de la vista. Un campo que tu aplicación añade a un objeto estándar obtiene su columna derivada bajo tu aplicación, en una vista propiedad de Twenty.
<Warning>
Las vistas de sistema y sus columnas son **propiedad del servidor**: resuelve sus identificadores
para hacer referencia a ellas, nunca para declararlas. `key` en
[`defineView()`](/l/es/developers/extend/apps/layout/views) está en desuso y se
ignora, por lo que una vista de manifiesto nunca puede reclamar la clave
`INDEX`, y el servidor ya aprovisiona una columna para cada campo que añades,
de modo que declarar tu propio `defineViewField()` para ese mismo campo en una
vista de sistema entra en conflicto con ella.
</Warning>
## Objetos estándar de Twenty
Para un objeto **estándar** de Twenty (Persona, Compañía, Oportunidad, …), no necesitas derivar nada: los identificadores están precalculados como constantes que puedes importar directamente, tanto para campos como para vistas.
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.createdAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.fields.updatedAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople.universalIdentifier
```
Recurre a los ayudantes anteriores cuando el objeto es uno que **tu aplicación** define con [`defineObject()`](/l/es/developers/extend/apps/data/objects), donde no existe tal constante.
<Note>
`name` es un campo **predeterminado**, no un campo de sistema. Conserva su propio identificador universal fijo
y no se resuelve mediante
`getFieldUniversalIdentifier`. En los objetos que defines, haz referencia al campo
`name` mediante el identificador que le diste en `defineObject()`.
</Note>

View File

@@ -0,0 +1,184 @@
---
title: Tipos de actividad del cronograma
description: Definir eventos de auditoría automática y eventos de aplicación explícitos que renderizan en cronogramas de registro.
icon: clock-rotate-left
---
Un tipo de actividad de línea de tiempo define el vocabulario estable para un evento que se muestra en la línea de tiempo de un registro. Los objetos estándar y los objetos de aplicación utilizan el mismo contrato: un tipo tiene una etiqueta y un icono, puede opcionalmente declarar cuando se emita, y puede renderizar opcionalmente a través de uno de los [componentes frontales] de tu aplicación (/developers/extend/apps/layout/front-components).
<Note>
Los tipos de actividad de la línea de tiempo están en fase beta y pronto estarán disponibles en Veinte 2.34. La
API puede evolucionar mientras aprendemos de los casos de uso de los desarrolladores.
</Note>
Crea uno con el andamiante:
```bash filename="Terminal"
yarn twenty dev:add timelineActivityType
```
O definirlo directamente:
```ts filename="src/timeline-activity-types/post-card-created.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7500',
name: 'postCardCreated',
label: 'created a post card',
icon: 'IconMail',
emit: {
on: 'created',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
},
});
```
## Eventos explícitos y automáticos
Añade `emit` cuando Veenty debería crear este tipo automáticamente. `emit.on` soporta `created`, `updated`, `deleted`, `restored`, `linked`, y `unlinked`, mientras que `emit.objectUniversalIdentifier` identifica el objeto fuente. Sólo un tipo efectivo puede manejar la misma clave de emisión—`on`, objeto y opcional a través de la relación—en un espacio de trabajo.
Sin `emit.throughh`, el evento está escrito en la línea de tiempo del registro fuente. Para aficionarlo a registros relacionados, establezca `emit.throughh. elationFieldUniversalIdentifier` a una relación directa de muchos-a-uno o a una [relación de unión uno-a-muchos](/l/es/developers/extend/apps/data/relations#junction-relations) en el objeto fuente. Las relaciones morfológicas se dirigen a cada miembro de su grupo morfológico, por lo que una sola declaración puede centrarse en varios tipos de objetos.
Para una relación directa, crear o restaurar el registro fuente produce `linked`, eliminándolo produce `unlinked`, y reapuntando la relación produce `unlinked` en el objetivo anterior más `linked` en el nuevo objetivo. Otras actualizaciones de código fuente no producen eventos de enlace. Este es el contrato utilizado por los archivos adjuntos, cuyo objetivo es una relación directa con la morfa.
Para una relación de juncción, `universalSettings.junctionTargetFieldUniversalIdentifier` identifica la relación del objeto de unión con el objetivo. Crear o eliminar una fila de unión produce `linked` o `unlinked`. Reapuntando a cualquiera de los lados de un registro de junction también produce un evento de enlace; las actualizaciones a campos de junction no relacionados no lo hacen.
Los eventos `linked` y `unlinked` requieren `emit.throughh`, porque su disparador es un cambio en la relación directa o de unión configurada.
Por ejemplo, este es el mismo contrato genérico utilizado por notas, tareas, mensajes y eventos del calendario:
```ts filename="src/timeline-activity-types/post-card-linked.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/post-card-recipients-on-post-card.field';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7502',
name: 'postCardLinked',
label: 'received a post card',
icon: 'IconMail',
emit: {
on: 'linked',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
through: {
relationFieldUniversalIdentifier:
POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER,
},
},
});
```
Por defecto se emite un evento `actualizado` a través de cada actualización de fuente. Establece `emit.through.triggerFieldUniversalIdentifiers` cuando solo los cambios a los campos de origen seleccionados deben aparecer en las líneas de tiempo de destino.
Omita `emit` para un evento de dominio explícito que tu función lógica se crea. Esto evita producir tanto una fila de auditoría automática como una fila explícita para la misma operación.
```ts filename="src/timeline-activity-types/post-card-sent.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
});
```
Crea eventos explícitos con [`createTimelineActivity()`](/l/es/developers/extend/apps/logic/logic-functions#create-a-timeline-activity). El código de la aplicación utiliza identificadores universales estables; Veinte resuelve los identificadores de metadatos específicos de la instalación.
## Procesamiento personalizado
Sin un componente frontal, Veinte renderiza una fila genérica nativa de los metadatos de tipo etiqueta, icono y objeto enlazado. Esto funciona para objetos estándar y personalizados sin un renderizador específico de objetos.
Para detalles personalizados, establezca `frontComponentUniversalIdentifier` en un componente frontal propiedad de la misma aplicación:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
La fila nativa sigue siendo la presentación colapsada. Veinte monta el componente frontal sólo después de que el usuario amplíe la fila, evitando un sandbox y un trabajador para cada evento visible. Dentro del componente, llame a `useTimelineActivityId()` desde `twenty-sdk/front-component` para leer el ID de la fila y obtener cualquier dato que su presentación necesite. Devuelve `null` cuando el componente es renderizado fuera de una fila de cronología.
## Reemplazar la presentación de otra aplicación
Una aplicación posee contratos de cronología automáticos para sus propios objetos. Para personalizar un evento en un objeto propiedad de otra aplicación, declare explícitamente el tipo existente con `replacesTimelineActivityTypeUniversalIdentifier`:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7503',
name: 'companyCreatedWithDeploymentContext',
label: 'was created from a deployment',
emit: {
on: 'created',
objectUniversalIdentifier: COMPANY_UNIVERSAL_IDENTIFIER,
},
replacesTimelineActivityTypeUniversalIdentifier:
RECORD_CREATED_TIMELINE_ACTIVITY_TYPE_UNIVERSAL_IDENTIFIER,
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
Una sobreescritura debe incluir `emit`, porque reemplaza una ranura de emisión automática existente en lugar de un tipo explícito. El tipo referenciado debe pertenecer a la aplicación del objeto objetivo y describir la misma acción y ruta. Eliminar la aplicación de sobre-escritura restaura el tipo base; eliminar el tipo base desactiva la anulación.
## Superposición y silenciamiento del espacio de trabajo
Los administradores del espacio de trabajo pueden cambiar la presentación de un tipo o silenciar sus eventos automáticos y explícitos sin bifurcar la aplicación. Usa la mutación de la API de metadatos `updateTimelineActivityType` con el ID de tipo específico de la instalación:
```graphql
mutation CustomizeTimelineActivityType(
$id: UUID!
$label: String
$icon: String
$isActive: Boolean
) {
updateTimelineActivityType(
input: { id: $id, label: $label, icon: $icon, isActive: $isActive }
) {
id
label
icon
isActive
}
}
```
`label` e `icon` se almacenan como espacios de trabajo sobrescritos, por lo que las actualizaciones posteriores de la aplicación no sobreescriben las opciones del administrador. Establece `isActive` en `false` para detener la emisión automática y rechazar nuevos eventos explícitos de ese tipo. Las filas existentes siguen siendo visibles.
Restaurar los valores predeterminados de la aplicación, incluyendo el estado activo, con:
```graphql
mutation ResetTimelineActivityType($id: UUID!) {
resetTimelineActivityType(id: $id) {
id
label
icon
isActive
}
}
```
## Campos de configuración
| Campo | Obligatorio | Descripción |
| ------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------- |
| `universalIdentifier` | Sí | UUID estable para el tipo entre instalaciones y actualizaciones |
| `name` | Sí | Nombre programático local de la aplicación; debe ser único dentro de la aplicación |
| `label` | Sí | Texto de acción de cara al usuario usado por el renderizador nativo |
| `icon` | No | Veinte nombre de icono mostrado al lado del evento |
| `emit` | No | Declaración de emisión automática; omisión de tipos sólo explícitos |
| `emit.on` | Con emisión | Acción de auditoría que hace que este tipo se escriba |
| `emit.objectUniversalIdentifier` | Con emisión | Objeto cuyos registros emiten este tipo |
| `emit.through.relationFieldUniversalIdentifier` | No | Relación directa o de unión usada para reavivar las líneas de tiempo relacionadas |
| `emit.through.triggerFieldUniversalIdentifiers` | No | Campos de origen que pueden desencadenar un evento 'actualizado' |
| `frontComponentUniversalIdentifier` | No | Componente frontal propiedad de la aplicación montado cuando la fila es expandida |
| `replacesTimelineActivityTypeUniversalIdentifier` | No | Tipo de emisión existente reemplazado; requiere `emit` |
Veinte instantáneas capturan identidad semántica y presentaciones duraderas cuando se crea un evento. Las filas históricas mantienen su acción original y su objeto significado. Mientras el tipo está instalado, su actual etiqueta traducida, icono y componente frontal renderizan en vivo; después de desinstalar, la instantánea mantiene el registro legible. La resolución utiliza el identificador universal, así que la presentación también sobrevive a reinstalar la aplicación.

View File

@@ -0,0 +1,102 @@
---
title: Conceptos
description: "Cómo funcionan las aplicaciones de Twenty: modelo de entidad, sandboxing y ciclo de vida de la instalación."
icon: sitemap
---
Las aplicaciones de Twenty son paquetes de TypeScript que amplían tu espacio de trabajo con objetos personalizados, lógica, componentes de UI y capacidades de IA. Se ejecutan en la plataforma Twenty con sandboxing completo y controles de permisos.
## Cómo funcionan las aplicaciones
Una aplicación es un conjunto de **entidades** declaradas usando funciones `defineEntity()` del paquete `twenty-sdk`. El SDK detecta estas declaraciones mediante análisis de AST en tiempo de compilación y produce un **manifiesto** — una descripción completa de lo que tu aplicación agrega a un espacio de trabajo. Estas funciones validan tu configuración en tiempo de compilación y proporcionan autocompletado en el IDE y seguridad de tipos.
```
your-app/
├── src/
│ ├── application-config.ts ← defineApplication (required, one per app)
│ ├── roles/ ← defineRole
│ ├── objects/ ← defineObject
│ ├── fields/ ← defineField
│ ├── logic-functions/ ← defineLogicFunction
│ ├── front-components/ ← defineFrontComponent
│ ├── skills/ ← defineSkill
│ ├── agents/ ← defineAgent
│ ├── views/ ← defineView
│ ├── navigation-menu-items/ ← defineNavigationMenuItem
│ └── page-layouts/ ← definePageLayout
├── public/ ← Static assets (images, icons)
└── package.json
```
<Note>
**La organización de archivos depende de ti.** La detección de entidades se basa en el AST — el SDK encuentra llamadas a `export default defineEntity(...)` sin importar dónde se encuentre el archivo. La estructura de carpetas anterior es una convención, no un requisito.
</Note>
## Tipos de entidades
| Entidad | Propósito | Documentación |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| **Aplicación** | Identidad de la aplicación, rol predeterminado, variables | [Configuración de la aplicación](/l/es/developers/extend/apps/config/application) |
| **Rol** | Conjuntos de permisos para objetos y campos | [Roles y permisos](/l/es/developers/extend/apps/config/roles) |
| **Objeto** | Tipos de registros personalizados con campos | [Objetos](/l/es/developers/extend/apps/data/objects) |
| **Campo** | Agregar campos a objetos de otras aplicaciones | [Ampliar objetos](/l/es/developers/extend/apps/data/extending-objects) |
| **Relación** | Vínculos bidireccionales entre objetos | [Relaciones](/l/es/developers/extend/apps/data/relations) |
| **Función de lógica** | TypeScript del lado del servidor con activadores | [Funciones de lógica](/l/es/developers/extend/apps/logic/logic-functions) |
| **Habilidad** | Instrucciones reutilizables para agentes de IA | [Habilidades y agentes](/l/es/developers/extend/apps/logic/skills-and-agents) |
| **Agente** | Asistentes de IA con prompts personalizados | [Habilidades y agentes](/l/es/developers/extend/apps/logic/skills-and-agents) |
| **Proveedor de conexión** | Credenciales OAuth para APIs de terceros | [Conexiones](/l/es/developers/extend/apps/logic/connections) |
| **Vista** | Vistas de listas de registros preconfiguradas | [Vistas](/l/es/developers/extend/apps/layout/views) |
| **Elemento del menú de navegación** | Entradas personalizadas de la barra lateral | [Elementos del menú de navegación](/l/es/developers/extend/apps/layout/navigation-menu-items) |
| **Diseño de página** | Pestañas y widgets en la página de detalles de un registro | [Diseños de página](/l/es/developers/extend/apps/layout/page-layouts) |
| **Componente de frontend** | Interfaz de usuario de React en entorno aislado dentro de Twenty | [Componentes de frontend](/l/es/developers/extend/apps/layout/front-components) |
| **Dependencias compartidas** | Dependencias compartidas por los componentes del frontend de la aplicación, declaradas en package.json | [Dependencias compartidas](/l/es/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components) |
| **Elemento del menú de comandos** | Acciones rápidas y entradas Cmd+K | [Elementos del menú de comandos](/l/es/developers/extend/apps/layout/command-menu-items) |
## Sandboxing
* **Las funciones de lógica** se ejecutan en procesos de Node.js aislados en el servidor. Solo acceden a los datos a través del cliente de API tipado, que actúa como la persona que desencadenó la ejecución o como la propia aplicación. Consulta [Qué acceso usa una llamada](/l/es/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses).
* **Los componentes de frontend** se ejecutan en Web Workers usando Remote DOM — aislados de la página principal pero renderizando elementos DOM nativos (no iframes). Se comunican con Twenty a través de una API de host de paso de mensajes.
* **Los permisos** se aplican a nivel de API. Se inyectan dos tokens de tiempo de ejecución, ambos derivados del rol declarado con `defineApplicationRole()`; el predeterminado se restringe aún más a lo que puede hacer la persona que desencadena la ejecución siempre que haya alguien detrás de ella.
## Ciclo de vida de la aplicación
```
┌─────────────────────────────────────────────────────────┐
│ Development │
│ npx create-twenty-app → yarn twenty dev (live sync) │
├─────────────────────────────────────────────────────────┤
│ Build & Deploy │
│ yarn twenty dev:build → yarn twenty app:publish │
├─────────────────────────────────────────────────────────┤
│ Install flow │
│ upload → [pre-install] → metadata migration → │
│ generate SDK → [post-install] → … → [uninstall] │
├─────────────────────────────────────────────────────────┤
│ Publish │
│ npm publish → appears in Twenty marketplace │
└─────────────────────────────────────────────────────────┘
```
* **`yarn twenty dev`** — observa tus archivos fuente y sincroniza en tiempo real los cambios con un servidor de Twenty conectado. El cliente de API tipado se regenera automáticamente cuando cambia el esquema.
* **`yarn twenty dev:build`** — compila TypeScript, agrupa las funciones de lógica y los componentes de frontend con esbuild, y produce un manifiesto.
* **Hooks de pre/post-instalación y desinstalación** — funciones opcionales que se ejecutan durante la instalación o justo antes de la desinstalación. Consulta [Hooks de instalación](/l/es/developers/extend/apps/config/install-hooks) para más detalles.
## Próximos pasos
<CardGroup cols={2}>
<Card title="Configuración" icon="screwdriver-wrench" href="/l/es/developers/extend/apps/config/overview">
Identidad de la aplicación, rol predeterminado y hooks de instalación y desinstalación.
</Card>
<Card title="Datos" icon="database" href="/l/es/developers/extend/apps/data/overview">
Objetos, campos y relaciones bidireccionales.
</Card>
<Card title="Lógica" icon="bolt" href="/l/es/developers/extend/apps/logic/overview">
Funciones de lógica, habilidades, agentes y conexiones OAuth.
</Card>
<Card title="Diseño" icon="table-columns" href="/l/es/developers/extend/apps/layout/overview">
Vistas, navegación, diseños de página y componentes de frontend.
</Card>
<Card title="Operaciones" icon="rocket" href="/l/es/developers/extend/apps/operations/overview">
CLI, pruebas, remotos, CI y publicación de tu aplicación.
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: Servidor local
description: "Administra el servidor Docker local de Twenty: inícialo, deténlo, actualízalo, crea una instancia de prueba en paralelo y realiza la configuración manual del SDK."
icon: server
---
## Administrar el servidor local
Usa `yarn twenty docker:*` para controlar el contenedor local de Twenty:
| Comando | Qué hace |
| -------------------------------------- | ----------------------------------------------------------------- |
| `yarn twenty docker:start` | Inicia el servidor local (descarga la imagen si es necesario) |
| `yarn twenty docker:start 2.2.0` | Iniciar una versión específica del servidor |
| `yarn twenty docker:start --port 3030` | Inicia en un puerto personalizado |
| `yarn twenty docker:stop` | Detiene el servidor (conserva los datos) |
| `yarn twenty docker:status` | Muestra la URL, la versión y las credenciales de inicio de sesión |
| `yarn twenty docker:logs` | Transmite los registros del servidor |
| `yarn twenty docker:reset` | Elimina los datos y comienza desde cero |
| `yarn twenty docker:upgrade` | Descarga la última imagen `twenty-app-dev` |
| `yarn twenty docker:upgrade 2.2.0` | Actualiza a una versión específica |
Los datos se conservan entre reinicios en dos volúmenes de Docker (`twenty-app-dev-data` para PostgreSQL, `twenty-app-dev-storage` para archivos). Usa `reset` para borrar todo.
## Fijar la versión del servidor
Cuando no se pasa ninguna versión, `docker:start` resuelve la versión a partir del rango `engines.twenty` de tu aplicación en `package.json`, el mismo rango con el que el servidor valida cuando tu aplicación se instala. Inicia la imagen publicada más reciente de `twenty-app-dev` que satisface el rango, recurriendo a `latest` cuando el campo está ausente o ninguna versión publicada coincide:
```json filename="package.json"
{
"engines": {
"twenty": ">=2.2.0"
}
}
```
Pasa una versión explícitamente para anular el rango en una sola ejecución: `yarn twenty docker:start 2.3.0`. Si ya existe un contenedor en una versión diferente, `docker:start` lo actualiza in situ (recreando el contenedor mientras conserva tus volúmenes de datos).
## Actualización de la imagen del servidor
`yarn twenty docker:upgrade` descarga la última imagen, compara los digests y solo recrea el contenedor si realmente cambió algo. Los volúmenes se conservan — solo se reemplaza el contenedor. Si se descargó una nueva imagen y el contenedor estaba en ejecución, la actualización inicia automáticamente un contenedor nuevo; ejecuta `yarn twenty docker:start` después para esperar a que esté en buen estado.
```bash filename="Terminal"
yarn twenty docker:upgrade # Latest
yarn twenty docker:upgrade 2.2.0 # Specific version
```
Puedes verificar la versión en ejecución con `yarn twenty docker:status` (muestra el `APP_VERSION` incluido en el contenedor).
## Ejecutar una instancia de prueba en paralelo
Pasa `--test` a cualquier comando de `docker:*` para administrar una segunda instancia completamente aislada — útil para ejecutar pruebas de integración o experimentar sin tocar los datos principales de desarrollo:
| Comando | Qué hace |
| ----------------------------------- | -------------------------------------------------------------- |
| `yarn twenty docker:start --test` | Inicia la instancia de prueba (usa el puerto 2021 por defecto) |
| `yarn twenty docker:stop --test` | Deténla |
| `yarn twenty docker:status --test` | Muestra su estado |
| `yarn twenty docker:logs --test` | Transmite sus registros |
| `yarn twenty docker:reset --test` | Borra sus datos |
| `yarn twenty docker:upgrade --test` | Actualiza su imagen |
La instancia de prueba se ejecuta en su propio contenedor de Docker (`twenty-app-dev-test`) con volúmenes dedicados (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) y configuración dedicada, por lo que puede ejecutarse en paralelo con la instancia principal sin conflictos. Combina `--test` con `--port` para anular el puerto predeterminado (2021).
## Configuración manual (sin el generador)
Omite el generador si vas a agregar el SDK a un proyecto existente:
```bash filename="Terminal"
yarn add twenty-sdk twenty-client-sdk
```
Agrega el script a `package.json`:
```json filename="package.json"
{
"scripts": {
"twenty": "twenty"
}
}
```
Ahora puedes ejecutar `yarn twenty dev`, `yarn twenty docker:start` y todos los demás comandos.
<Note>
No instales `twenty-sdk` globalmente — fíjalo por proyecto para que cada app use su propia versión.
</Note>

View File

@@ -0,0 +1,79 @@
---
title: Estructura del proyecto
description: Qué hay dentro de una app de Twenty generada mediante scaffolding — archivos, carpetas y lo que hace cada uno.
icon: folder-tree
---
Una nueva app generada por `npx create-twenty-app` se ve así:
```text filename="my-twenty-app/"
my-twenty-app/
package.json
src/
application-config.ts # Required — your app's entry point
default-role.ts # Permissions for logic functions
constants/
universal-identifiers.ts # Auto-generated UUIDs and metadata
front-components/
main-page.tsx # Welcome page component
navigation-menu-items/
main-page.navigation-menu-item.ts # Sidebar entry for the welcome page
page-layouts/
main-page.page-layout.ts # Standalone page hosting the component
__tests__/
application-config.test.ts # Unit test
global-setup.ts # Integration test setup (sync + uninstall)
schema.integration-test.ts # Integration test against a live server
.github/workflows/
ci.yml # Lint, typecheck, unit + integration tests
cd.yml # Deploy + install on push to main
publish.yml # Publish to npm on version tags (with provenance)
public/
logo.svg # Static assets
vitest.config.ts # Integration test runner config
vitest.unit.config.ts # Unit test runner config
tsconfig.json, tsconfig.spec.json
.nvmrc, .yarnrc.yml, .oxlintrc.json
README.md, AGENTS.md, CLAUDE.md, CHANGELOG.md, SETUP.md
```
## Archivos clave
| Archivo / Carpeta | Propósito |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `src/application-config.ts` | **Obligatorio.** El archivo de configuración principal de tu app. |
| `src/default-role.ts` | Rol predeterminado que controla a qué pueden acceder tus funciones lógicas. |
| `src/constants/universal-identifiers.ts` | UUIDs generados automáticamente y metadatos de la app (nombre para mostrar, descripción). |
| `src/front-components/`, `src/navigation-menu-items/`, `src/page-layouts/` | Una página de bienvenida inicial: un front component renderizado por un page layout independiente, accesible desde la barra lateral. |
| `src/__tests__/` | Una prueba unitaria más una prueba de integración (con su configuración global) que sincroniza la aplicación contra un servidor real. |
| `public/` | Recursos estáticos (imágenes, fuentes) servidos con tu app. |
| `AGENTS.md` / `CLAUDE.md` | Guía para agentes de IA para programación que trabajan en la aplicación. |
| `CHANGELOG.md` / `SETUP.md` | Registro de cambios destacados e instrucciones de configuración para el desarrollo local. |
<Note>
**La organización de archivos depende de ti.** Las carpetas anteriores son convenciones: el SDK detecta entidades mediante análisis AST en llamadas a `export default defineEntity(...)`, sin importar dónde se encuentre el archivo.
</Note>
## Dependencias
Ambos paquetes del SDK de Twenty pertenecen a `devDependencies`, no a `dependencies`:
```json filename="package.json"
{
"dependencies": {},
"devDependencies": {
"twenty-client-sdk": "2.20.0",
"twenty-sdk": "2.20.0",
"twenty-ui": "1.0.0-alpha.1"
}
}
```
El generador fija `twenty-sdk` y `twenty-client-sdk` a su propia versión; mantén ambos sincronizados al actualizar.
* **`twenty-sdk`** incluye el CLI `twenty` y las herramientas de build/scaffolding. Solo se ejecuta en el desarrollo y durante el build, y nunca lo importa el runtime de la app que publicas.
* **`twenty-client-sdk`** *sí* es importado por el código de tu app (`CoreApiClient`, `MetadataApiClient`, `RestApiClient`), pero Twenty lo proporciona en tiempo de ejecución: las funciones lógicas lo obtienen de una capa SDK generada y los componentes de front lo resuelven desde módulos servidos por el servidor. Tu copia instalada solo se utiliza para la comprobación de tipos y el build en tiempo de despliegue, por lo que nunca necesita incluirse en el bundle desplegado.
Mantener cualquiera de los paquetes bajo `dependencies` lo introduce en el bundle de runtime de la app instalada, donde es peso muerto. `twenty dev:build` emite una advertencia cuando cualquiera de ellos sigue listado bajo `dependencies`.
Añade las dependencias de runtime propias de tu app (las bibliotecas que tus funciones lógicas realmente importan en tiempo de ejecución) bajo `dependencies` como de costumbre. Mantén el árbol ligero: las `dependencies` se instalan en una capa de tiempo de ejecución con un límite de 250 MB sin comprimir, y una sincronización o instalación falla con un error de tamaño de dependencias si se supera ese límite — las bibliotecas de UI y las herramientas de desarrollo pertenecen a `devDependencies`.

View File

@@ -0,0 +1,166 @@
---
title: Inicio rápido
icon: rocket
description: Crea tu primera aplicación de Twenty en minutos.
---
## Prerrequisitos
* **Node.js 24.5+** — [Descargar](https://nodejs.org/)
* **Yarn 4** — incluido con Node.js a través de Corepack. Actívalo: `corepack enable`
* **Docker** — [Descargar](https://www.docker.com/products/docker-desktop/). Necesario para ejecutar un servidor local de Twenty. Omítelo si ya tienes Twenty ejecutándose en otro lugar.
La creación de una app de Twenty tiene tres fases. El generador las combina en un único comando de ruta ideal, pero cada fase es un concepto independiente — cuando algo falla, saber en qué fase estás te indica qué debes corregir.
| Fase | Qué haces | Herramienta | Resultado |
| --------------------------- | --------------------------------------------------- | ----------------------------------- | ------------------------------------ |
| **1. Generar estructura** | Genera el código fuente de la app | `npx create-twenty-app` | Un proyecto de TypeScript en disco |
| **2. Ejecutar un servidor** | Inicia un servidor de Twenty con el que sincronizar | Docker + `yarn twenty docker:start` | Una instancia de Twenty en ejecución |
| **3. Sincronizar** | Sincroniza en vivo tu código con el servidor | `yarn twenty dev` | Tus cambios aparecen en la UI |
---
## Fase 1 — Genera la estructura de tu proyecto
Crea una app nueva a partir de la plantilla:
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app
```
El generador no es interactivo: el nombre del directorio se convierte en el nombre de la aplicación. Pasa `--display-name` y `--description` para personalizar los metadatos generados (también puedes editarlos más tarde en `src/constants/universal-identifiers.ts`). Esto genera un proyecto de TypeScript en `my-twenty-app/` con un `application-config.ts` inicial, un rol predeterminado, flujos de trabajo de CI/CD y una prueba de integración.
**Después de esta fase:** tienes el código fuente de tu app en tu máquina. Aún no se está ejecutando — esa es la Fase 2.
---
## Fase 2 — Ejecuta un servidor local de Twenty
Tu app necesita un servidor de Twenty con el que sincronizar. El servidor es una instancia completa de Twenty — UI, API GraphQL, PostgreSQL — ejecutándose localmente en Docker. Tu código local sube sus definiciones a ese servidor, lo que hace que aparezcan en la UI.
El generador inicia uno por ti: con Docker en ejecución, extrae la imagen `twentycrm/twenty-app-dev`, la inicia en el puerto `2020` y autentica la CLI contra el espacio de trabajo de demostración preconfigurado (`tim@apple.dev`), sin necesidad de iniciar sesión.
Para conectarte a un servidor Twenty existente en su lugar, pasa `--url \<your-server-url>`. Los servidores remotos se autentican con OAuth: se abre un navegador para que puedas iniciar sesión y hacer clic en **Authorize**, lo que le da a la CLI acceso a tu espacio de trabajo. (También puedes optar por usar OAuth localmente con `--authentication-method oauth`: inicia sesión con `tim@apple.dev` / `tim@apple.dev`.)
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/login.png" alt="Pantalla de inicio de sesión de Twenty" />
</div>
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/authorize.png" alt="Pantalla de autorización de la CLI de Twenty" />
</div>
Tu terminal confirmará que todo está configurado.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/scaffolded.png" alt="Aplicación generada correctamente" />
</div>
**Después de esta fase:** tienes un servidor de Twenty en ejecución en [http://localhost:2020](http://localhost:2020) con tu CLI autorizada para sincronizar con él.
<Note>
Si Docker no está instalado o en ejecución, el generador te indicará el comando de inicio correcto para tu sistema operativo. Una vez que Docker esté en marcha, puedes reanudar con `yarn twenty docker:start` — no es necesario volver a generar la estructura.
</Note>
---
## Fase 3 — Sincroniza tus cambios
Este es el ciclo interno en el que pasarás la mayor parte del tiempo.
```bash filename="Terminal"
cd my-twenty-app
yarn twenty dev
```
Esto observa `src/`, recompila en cada cambio y sincroniza el resultado con el servidor. Edita un archivo, guarda y, en cuestión de unos segundos, el servidor reflejará el cambio. Verás un panel de estado en vivo en tu terminal.
Para una salida más detallada (registros de compilación, solicitudes de sincronización, trazas de errores), añade `--verbose`.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/dev.png" alt="Salida del modo de desarrollo en la terminal" />
</div>
Abre [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer) en tu navegador. Deberías ver tu app listada en **Your Apps**.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="Lista de Your Apps que muestra My twenty app" />
</div>
Haz clic en **My twenty app** para ver su **registro de la aplicación** — un registro a nivel de servidor que describe tu app (nombre, identificador, credenciales de OAuth, origen). Un único registro puede instalarse en varios espacios de trabajo del mismo servidor.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="Detalles del registro de la aplicación" />
</div>
Haz clic en **View installed app** para ver la instalación en el espacio de trabajo. La pestaña **About** muestra la versión actual y las opciones de gestión.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="Aplicación instalada" />
</div>
**Después de esta fase:** tienes un ciclo de desarrollo en vivo. Edita cualquier archivo en `src/` y aparecerá en la UI.
### Sincronización de una sola vez para CI y scripts
Usa `plan` y `apply` para ejecutar la misma canalización una vez, sin observador:
```bash filename="Terminal"
yarn twenty plan # preview the metadata changes without applying them
yarn twenty apply # show the plan, then apply it
```
| Comando | Comportamiento | Cuándo usarlo |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `yarn twenty dev` | Supervisa tus archivos fuente y vuelve a sincronizar en cada cambio. Se ejecuta hasta que lo detengas. | Desarrollo local interactivo. |
| `yarn twenty apply` | Realiza una sola compilación + sincronización y luego sale con el código `0` si tiene éxito o `1` si falla. Pide confirmación para cambios destructivos (pasa `--force` para omitirla). | CI, hooks de pre-commit, agentes de IA, flujos de trabajo con scripts. |
| `yarn twenty plan` | Genera y muestra los cambios de metadatos **sin aplicarlos**. | Inspeccionar qué cambiaría una sincronización antes de confirmarla. |
Todos los modos necesitan un remoto autenticado. Consulta [Sincronización y recuperación](/l/es/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan) para obtener más información sobre `plan`.
<Note>
`yarn twenty dev --once` y `yarn twenty dev --once --dry-run` son alias obsoletos de `yarn twenty apply` y `yarn twenty plan`.
</Note>
### Opciones del modo de desarrollo
| Opción | Descripción |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `--force` | Aplica cambios destructivos (eliminaciones) sin confirmación. |
| `--debounceMs \<ms>` | Establece el tiempo de antirrebote para los cambios de archivo en milisegundos (valor predeterminado: `1000`). |
| `--verbose` / `--debug` | Muestra registros de compilación detallados, solicitudes de sincronización y seguimientos de errores. |
## Lo que puedes crear
Las apps se componen de **entidades** — cada una definida como un archivo de TypeScript con un único `export default`:
| Entidad | Qué hace |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **Objetos y campos** | Modelos de datos personalizados (Post Card, Invoice, etc.). con campos tipados |
| **Funciones de lógica** | Funciones de TypeScript del lado del servidor activadas por rutas HTTP, programaciones de cron o eventos de base de datos |
| **Componentes de frontend** | Componentes de React que se renderizan dentro de la interfaz de Twenty (panel lateral, widgets, menú de comandos) |
| **Habilidades y agentes** | Capacidades de IA — instrucciones reutilizables y asistentes autónomos |
| **Vistas y navegación** | Vistas de lista preconfiguradas y elementos del menú lateral |
| **Diseños de página** | Páginas de detalle de registros personalizadas con pestañas y widgets |
Referencia completa: [Conceptos](/l/es/developers/extend/apps/getting-started/concepts).
## Próximos pasos
<CardGroup cols={2}>
<Card title="Configuración" icon="screwdriver-wrench" href="/l/es/developers/extend/apps/config/overview">
Identidad de la aplicación, rol predeterminado, hooks de instalación y desinstalación, recursos públicos.
</Card>
<Card title="Datos" icon="database" href="/l/es/developers/extend/apps/data/overview">
Objetos, campos y relaciones bidireccionales.
</Card>
<Card title="Lógica" icon="bolt" href="/l/es/developers/extend/apps/logic/overview">
Funciones de lógica, habilidades, agentes y conexiones OAuth.
</Card>
<Card title="Diseño" icon="table-columns" href="/l/es/developers/extend/apps/layout/overview">
Vistas, navegación, diseños de página y componentes de frontend.
</Card>
<Card title="Operaciones" icon="rocket" href="/l/es/developers/extend/apps/operations/overview">
CLI, pruebas, remotos, CI y publicación de tu aplicación.
</Card>
</CardGroup>

View File

@@ -0,0 +1,63 @@
---
title: Andamiaje
description: Genera archivos de entidad de forma interactiva con yarn twenty dev:add — objetos, campos, vistas, funciones de lógica y más.
icon: wand-magic-sparkles
---
En lugar de crear archivos de entidad a mano, usa el generador interactivo:
```bash filename="Terminal"
yarn twenty dev:add
```
Te pide que elijas un tipo de entidad y te guía por los campos requeridos; luego escribe un archivo listo para usar con un `universalIdentifier` estable y la llamada correcta a `defineEntity()`.
También puedes pasar el tipo de entidad directamente para omitir la primera pregunta:
```bash filename="Terminal"
yarn twenty dev:add object
yarn twenty dev:add logicFunction
yarn twenty dev:add frontComponent
```
## Tipos de entidad disponibles
| Tipo de entidad | Comando | Archivo generado |
| ---------------------------------- | ------------------------------------------ | --------------------------------------------------------- |
| Objeto | `yarn twenty dev:add object` | `src/objects/\<name>.ts` |
| Campo | `yarn twenty dev:add field` | `src/fields/\<name>.ts` |
| Función de lógica | `yarn twenty dev:add logicFunction` | `src/logic-functions/\<name>.ts` |
| Componente de frontend | `yarn twenty dev:add frontComponent` | `src/front-components/\<name>.tsx` |
| Rol | `yarn twenty dev:add role` | `src/roles/\<name>.ts` |
| Habilidad | `yarn twenty dev:add skill` | `src/skills/\<name>.ts` |
| Agente | `yarn twenty dev:add agent` | `src/agents/\<name>.ts` |
| Vista | `yarn twenty dev:add view` | `src/views/\<name>.ts` |
| Elemento del menú de navegación | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/\<name>.ts` |
| Diseño de página | `yarn twenty dev:add pageLayout` | `src/page-layouts/\<name>.ts` |
| Pestaña Diseño de página | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/\<name>.ts` |
| Elemento del menú de comandos | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/\<name>.ts` |
| Campo de vista | `yarn twenty dev:add viewField` | `src/view-fields/\<name>.ts` |
| Proveedor de conexión | `yarn twenty dev:add connectionProvider` | `src/connection-providers/\<name>.ts` |
| Tipo de actividad de la cronología | `yarn twenty dev:add timelineActivityType` | `src/timeline-activity-types/\<name>.ts` |
## Qué genera el generador
Cada tipo de entidad tiene su propia plantilla. Por ejemplo, `yarn twenty dev:add object` solicita:
1. **Nombre (singular)** — p. ej., `invoice`
2. **Nombre (plural)** — p. ej., `invoices`
3. **Etiqueta (singular)** — se completa automáticamente a partir del nombre (p. ej., `Invoice`)
4. **Etiqueta (plural)** — se completa automáticamente (p. ej., `Invoices`)
5. **¿Crear una vista y un elemento de navegación?** — si respondes que sí, el generador también crea una vista correspondiente y un enlace en la barra lateral para el nuevo objeto.
Otros tipos de entidad tienen indicaciones más simples: la mayoría solo piden un nombre.
El tipo de entidad `field` es más detallado: pide el nombre del campo, etiqueta, tipo (de una lista de todos los tipos de campo disponibles como `TEXT`, `NUMBER`, `SELECT`, `RELATION`, etc.) y el `universalIdentifier` del objeto de destino.
## Ruta de salida personalizada
Usa la opción `--path` para colocar el archivo generado en una ubicación personalizada:
```bash filename="Terminal"
yarn twenty dev:add logicFunction --path src/custom-folder
```

View File

@@ -0,0 +1,14 @@
---
title: Solución de problemas
description: "Problemas comunes en la primera ejecución: Docker, versión de Node, Yarn, dependencias."
icon: wrench
---
* **Errores de Docker** — Asegúrate de que Docker Desktop (o el daemon) esté en ejecución antes de `yarn twenty docker:start`. El mensaje de error mostrará el comando de inicio correcto para tu sistema operativo.
* **Versión de Node incorrecta** — Se necesita la versión 24.5 o superior (`engines.node: ^24.5.0`). Compruébalo con `node -v`.
* **Falta Yarn 4** — Ejecuta `corepack enable`.
* **Dependencias rotas** — `rm -rf node_modules && yarn install`.
* **Errores de `twenty-sdk` tras actualizar a la v2.8.0** — Pasó de `dependencies` a `devDependencies` en la v2.8.0. Consulta [Estructura del proyecto → Dependencias](/l/es/developers/extend/apps/getting-started/project-structure#dependencies).
* **`twenty dev:build` muestra una advertencia sobre `twenty-client-sdk` en `dependencies`** — Twenty lo proporciona en tiempo de ejecución, por lo que debería trasladarse a `devDependencies` junto con `twenty-sdk`. Consulta [Estructura del proyecto → Dependencias](/l/es/developers/extend/apps/getting-started/project-structure#dependencies).
¿Atascado? Pide ayuda en el [Discord de Twenty](https://discord.com/channels/1130383047699738754/1130386664812982322).

View File

@@ -0,0 +1,118 @@
---
title: Elementos del menú de comandos
description: Expón componentes de frontend como acciones rápidas y entradas del menú de comandos (Cmd+K) con defineCommandMenuItem.
icon: terminal
---
Un **elemento del menú de comandos** es el puente entre el usuario y un [componente de frontend](/l/es/developers/extend/apps/layout/front-components). Registra el componente en el menú de comandos de Twenty (Cmd+K) y, opcionalmente, como un botón de acción rápida anclado en la esquina superior derecha de la página.
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
label: 'Open Dashboard',
shortLabel: 'Dashboard',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
## Campos de configuración
| Campo | Obligatorio | Descripción |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `universalIdentifier` | Sí | ID único estable para el comando |
| `label` | Sí | Etiqueta completa mostrada en el menú de comandos (Cmd+K) |
| `frontComponentUniversalIdentifier` | Sí | El `universalIdentifier` del componente de frontend que abre este comando |
| `shortLabel` | No | Etiqueta corta mostrada en el botón de acción rápida anclado |
| `icon` | No | **Obsoleto**: se ignora en favor del icono de la aplicación; la compilación emite una advertencia si se establece |
| `isPinned` | No | Cuando es `true`, muestra el comando como un botón de acción rápida en la esquina superior derecha de la página |
| `availabilityType` | No | Controla dónde aparece el comando: `'GLOBAL'` (siempre disponible), `'GLOBAL_OBJECT_CONTEXT'` (solo en páginas con un contexto de objeto: páginas de índice y de registro), `'RECORD_SELECTION'` (solo cuando hay registros seleccionados) o `'FALLBACK'` (se muestra cuando ningún otro comando coincide) |
| `availabilityObjectUniversalIdentifier` | No | Restringe el comando a páginas de un tipo de objeto específico (p. ej., solo en registros de Company) |
| `conditionalAvailabilityExpression` | No | Una expresión booleana que controla dinámicamente la visibilidad (ver abajo) |
## Comandos sin interfaz
Un elemento del menú de comandos emparejado con un [headless front component](/l/es/developers/extend/apps/layout/front-components#headless-vs-non-headless) es la forma idónea de ofrecer una acción de un solo clic: ejecutar código, navegar o confirmar y ejecutar. La página Front Components abarca los [SDK Command components](/l/es/developers/extend/apps/layout/front-components#sdk-command-components) (`Command`, `CommandLink`, `CommandModal`, `CommandOpenSidePanelPage`) que gestionan el patrón de acción y desmontaje.
Un flujo típico: un componente sin interfaz gráfica renderiza `<Command execute={...} />` (consulta el [ejemplo completo](/l/es/developers/extend/apps/layout/front-components#sdk-command-components)), y el elemento del menú de comandos apunta a él:
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
## Expresiones de disponibilidad condicional
El campo `conditionalAvailabilityExpression` te permite controlar cuándo es visible un comando en función del contexto de la página actual. Importa variables tipadas y operadores desde `twenty-sdk` para construir expresiones:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '...',
label: 'Bulk Update',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: '...',
conditionalAvailabilityExpression: everyEquals(
objectPermissions,
'canUpdateObjectRecords',
true,
),
});
```
<Note>
`RECORD_SELECTION` ya implica una selección no vacía; usa `numberOfSelectedRecords` solo para recuentos específicos (por ejemplo, `>= 2`).
</Note>
### Variables de contexto
Estas representan el estado actual de la página:
| Variable | Tipo | Descripción |
| ------------------------------ | --------- | ------------------------------------------------------------------- |
| `pageType` | `string` | Tipo de página actual (p. ej., 'RecordIndexPage', 'RecordShowPage') |
| `isInSidePanel` | `boolean` | Si el componente se renderiza en un panel lateral |
| `numberOfSelectedRecords` | `number` | Número de registros seleccionados actualmente |
| `isSelectAll` | `boolean` | Si "seleccionar todo" está activo |
| `selectedRecords` | `array` | Los objetos de registro seleccionados |
| `favoriteRecordIds` | `array` | IDs de registros marcados como favoritos |
| `objectPermissions` | `object` | Permisos para el tipo de objeto actual |
| `targetObjectReadPermissions` | `object` | Permisos de lectura para el objeto de destino |
| `targetObjectWritePermissions` | `object` | Permisos de escritura para el objeto de destino |
| `featureFlags` | `object` | Indicadores de características activos |
| `objectMetadataItem` | `object` | Metadatos del tipo de objeto actual |
| `hasAnySoftDeleteFilterOnView` | `boolean` | Si la vista actual tiene un filtro de eliminación lógica |
### Operadores
Combina variables en expresiones booleanas:
| Operador | Descripción |
| ----------------------------------- | --------------------------------------------------------------------- |
| `isDefined(value)` | `true` si el valor no es null/undefined |
| `isNonEmptyString(value)` | `true` si el valor es una cadena no vacía |
| `includes(array, value)` | `true` si el arreglo contiene el valor |
| `includesEvery(array, prop, value)` | `true` si la propiedad de cada elemento incluye el valor |
| `every(array, prop)` | `true` si la propiedad es truthy en cada elemento |
| `everyDefined(array, prop)` | `true` si la propiedad está definida en cada elemento |
| `everyEquals(array, prop, value)` | `true` si la propiedad es igual al valor en cada elemento |
| `some(array, prop)` | `true` si la propiedad es truthy en al menos un elemento |
| `someDefined(array, prop)` | `true` si la propiedad está definida en al menos un elemento |
| `someEquals(array, prop, value)` | `true` si la propiedad es igual al valor en al menos un elemento |
| `someNonEmptyString(array, prop)` | `true` si la propiedad es una cadena no vacía en al menos un elemento |
| `none(array, prop)` | `true` si la propiedad es falsy en cada elemento |
| `noneDefined(array, prop)` | `true` si la propiedad es undefined en cada elemento |
| `noneEquals(array, prop, value)` | `true` si la propiedad no es igual al valor en ningún elemento |

View File

@@ -0,0 +1,849 @@
---
title: Componentes de frontend
description: Crea componentes de React que se renderizan dentro de la UI de Twenty con aislamiento en entorno sandbox.
icon: window-maximize
---
Los componentes de frontend son componentes de React que se renderizan directamente dentro de la UI de Twenty. Se ejecutan en un **Web Worker aislado** usando Remote DOM: tu código se ejecuta dentro de un iframe de origen opaco y aislado (sandboxed), pero su interfaz de usuario sigue renderizándose de forma nativa en la página en lugar de quedar confinada a ese iframe.
<Warning>
Los componentes de Front todavía están en desarrollo activo. Tu código se ejecuta contra un DOM parcial, no una página real del navegador, por lo que los usos avanzados pueden fallar, a menudo de forma silenciosa. Consulta [Limitaciones actuales](#current-limitations).
</Warning>
## Dónde se pueden usar los componentes de front
Los componentes de front pueden renderizarse en tres ubicaciones dentro de Twenty:
* **Panel lateral** — Los componentes de front no headless se abren en el panel lateral derecho. Este es el comportamiento predeterminado cuando un componente de front se activa desde el menú de comandos.
* **Widgets (tableros y páginas de registros)** — Los componentes de front pueden incrustarse como widgets dentro de los [diseños de página](/l/es/developers/extend/apps/layout/page-layouts). Al configurar un tablero o el diseño de una página de registro, los usuarios pueden agregar un widget de componente de front.
* **Configuración de la app** — Definido con [`defineSettingsFrontComponent()`](#custom-settings-component), el componente de front se renderiza como una sección dentro de la pestaña **Settings** de la app, en lugar de la interfaz de configuración de variables predeterminada.
Un componente de front por sí solo no es accesible desde la interfaz de usuario; necesitas *exponerlo*. Las tres formas de hacerlo son:
* **Emparejarlo con un [elemento del menú de comandos](/l/es/developers/extend/apps/layout/command-menu-items)**: lo registra en el menú de comandos (Cmd+K) y, de forma opcional, como una acción rápida fijada.
* **Incrustarlo como widget en un [diseño de página](/l/es/developers/extend/apps/layout/page-layouts)**: lo coloca en la página de detalles de un registro o en un tablero.
* **Definirlo con [`defineSettingsFrontComponent()`](#custom-settings-component)** — lo renderiza como una sección dentro de la pestaña **Settings** de la app, en lugar de la interfaz de configuración de variables predeterminada.
## Ejemplo básico
La forma más rápida de ver un componente de front en acción es emparejarlo con un [`defineCommandMenuItem`](/l/es/developers/extend/apps/layout/command-menu-items), de modo que aparezca como un botón de acción rápida en la esquina superior derecha de la página:
```tsx src/front-components/hello-world.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
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,
});
```
```ts src/command-menu-items/hello-world.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
shortLabel: 'Hello',
label: 'Hello World',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
Después de sincronizar con `yarn twenty dev` (o ejecutar una sola vez `yarn twenty apply`), la acción rápida aparece en la esquina superior derecha de la página:
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/quick-action.png" alt="Botón de acción rápida en la esquina superior derecha" />
</div>
Haz clic para renderizar el componente en línea.
## Campos de configuración
| Campo | Obligatorio | Descripción |
| --------------------- | ----------- | -------------------------------------------------------------------- |
| `universalIdentifier` | Sí | ID único estable para este componente |
| `component` | Sí | Una función de componente de React |
| `name` | No | Nombre para mostrar |
| `description` | No | Descripción de lo que hace el componente |
| `isHeadless` | No | Configura en `true` si el componente no tiene UI visible (ver abajo) |
## Colocar un componente de frontend en una página
Más allá de los comandos, puedes incrustar un componente de frontend directamente en una página de registro agregándolo como un widget en un **diseño de página**. Consulta [Diseños de página](/l/es/developers/extend/apps/layout/page-layouts) para más detalles.
## Componente de configuración personalizada
Para reemplazar la interfaz de configuración de variables autogenerada en la pestaña **Settings** de tu app con tu propio componente, defínelo con `defineSettingsFrontComponent` en lugar de `defineFrontComponent`. Toma los mismos [campos de configuración](#configuration-fields) (excepto `isHeadless`, que no se acepta ya que un componente de configuración siempre renderiza una interfaz de usuario visible) y, además, marca el componente como la interfaz de configuración de la aplicación.
El componente se renderiza como una sección **dentro** de la pestaña Settings, no como un reemplazo de toda la pestaña. Las secciones gestionadas por el sistema de Twenty — actualización automática, App URL y conexiones — siempre se renderizan por encima de ella y no pueden ser sobrescritas por la aplicación.
```tsx src/front-components/app-settings.tsx
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
const AppSettings = () => {
return (
<div style={{ padding: '20px' }}>
<h2>My app settings</h2>
{/* render your own configuration UI here */}
</div>
);
};
export default defineSettingsFrontComponent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'app-settings',
description: "Custom UI for the app's Settings tab",
component: AppSettings,
});
```
Solo se permite un componente de front de configuración por aplicación; declarar más de uno hace que la compilación falle. Cuando está presente, la pestaña **Settings** de la aplicación renderiza este componente en lugar de la interfaz de usuario de configuración de variables predeterminada.
## Headless vs no headless
Los componentes de front vienen en dos modos de renderizado controlados por la opción `isHeadless`:
**No headless (predeterminado)** — El componente renderiza una UI visible. Cuando se activa desde el menú de comandos, se abre en el panel lateral. Este es el comportamiento predeterminado cuando `isHeadless` es `false` o se omite.
**Headless (`isHeadless: true`)** — El componente se monta de forma invisible en segundo plano. No abre el panel lateral. Los componentes headless están diseñados para acciones que ejecutan lógica y luego se desmontan — por ejemplo, ejecutar una tarea asíncrona, navegar a una página o mostrar un modal de confirmación. Se combinan de forma natural con los componentes Command del SDK descritos a continuación.
```tsx src/front-components/sync-tracker.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds, enqueueSnackbar } from 'twenty-sdk/front-component';
import { useEffect } from 'react';
const SyncTracker = () => {
const [recordId] = useSelectedRecordIds();
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,
});
```
Como el componente devuelve `null`, Twenty omite renderizar un contenedor para él — no aparece espacio vacío en el diseño. El componente sigue teniendo acceso a todos los hooks y a la API de comunicación con el host.
## Componentes Command del SDK
El paquete `twenty-sdk` proporciona cuatro componentes auxiliares Command diseñados para componentes de front headless. Cada componente ejecuta una acción al montarse, gestiona los errores mostrando una notificación tipo snackbar y desmonta automáticamente el componente de front al finalizar.
Impórtalos desde `twenty-sdk/front-component`:
* **`Command`** — Ejecuta un callback asíncrono mediante la prop `execute`.
* **`CommandLink`** — Navega a una ruta de la aplicación. Props: `to`, `params`, `queryParams`, `options`.
* **`CommandModal`** — Abre un modal de confirmación. Si el usuario confirma, ejecuta el callback `execute`. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
* **`CommandOpenSidePanelPage`** — Abre una página del panel lateral. Las props dependen de `page` — por ejemplo, `ViewRecord` recibe `recordId` + `objectNameSingular` (además de un id opcional de `tab` para abrir el registro en una pestaña específica), otras páginas reciben `pageTitle` + `pageIcon`.
Aquí tienes un ejemplo completo de un componente de front headless que usa `Command` para ejecutar una acción desde el menú de comandos:
```tsx src/front-components/run-action.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
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,
});
```
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
Y un ejemplo que usa `CommandModal` para pedir confirmación antes de ejecutar:
```tsx src/front-components/delete-draft.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { CommandModal } from 'twenty-sdk/front-component';
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,
});
```
Y un ejemplo que usa `CommandOpenSidePanelPage` para abrir el registro actual en el panel lateral en una pestaña específica. `tab` es un id de pestaña de diseño de página (los diseños predeterminados usan ids como `company-tab-emails` o `company-tab-timeline`; los diseños personalizados usan el id propio de la pestaña). Si el id no existe en el diseño del registro, en su lugar se abre la pestaña predeterminada:
```tsx src/front-components/open-company-emails.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
CommandOpenSidePanelPage,
SidePanelPages,
useSelectedRecordIds,
} from 'twenty-sdk/front-component';
const OpenCompanyEmails = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
if (!recordId) {
return null;
}
return (
<CommandOpenSidePanelPage
page={SidePanelPages.ViewRecord}
recordId={recordId}
objectNameSingular="company"
tab="company-tab-emails"
resetNavigationStack={false}
/>
);
};
export default defineFrontComponent({
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
name: 'open-company-emails',
description: 'Opens the current company on its Emails tab',
component: OpenCompanyEmails,
isHeadless: true,
});
```
## Llamar a una función de lógica
Los componentes de front se ejecutan en el navegador dentro de un Web Worker aislado (sandboxed) dentro de un iframe de origen opaco, mientras que las [funciones de lógica](/l/es/developers/extend/apps/logic/logic-functions) se ejecutan en el servidor. No hay una llamada directa en el mismo proceso entre ambos; en su lugar, un componente de front accede a una función de lógica a través de HTTP.
Una función de lógica declarada con `httpRouteTriggerSettings` es accesible por HTTP en su ruta. `RestApiClient` trata las rutas que comienzan con `/s/` como rutas de la aplicación, las resuelve a la URL desde la que se sirven tus funciones y las autentica con `TWENTY_APP_ACCESS_TOKEN`.
> **En Twenty Cloud, las funciones de lógica activadas por HTTP se sirven en un dominio dedicado por espacio de trabajo** en `https://\<your-workspace-subdomain>.withtwenty.com\<path>`. Para clientes externos, copia la URL exacta desde la configuración de **HTTP trigger** de la función o desde la pestaña **Settings** de la aplicación.
Un componente de front sin interfaz (headless) puede ejecutar la llamada al montar mediante el componente `Command` y luego desmontarse automáticamente:
```tsx src/front-components/sync-prs.tsx
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
const SyncPrs = () => {
const execute = async () => {
await new RestApiClient().post('/s/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
La ruta que se pasa a `RestApiClient` es el `httpRouteTriggerSettings.path` de la función lógica, con el prefijo `/s`. Mantén `isAuthRequired: true`; el `TWENTY_APP_ACCESS_TOKEN` que Twenty crea para tu componente autentica la solicitud:
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
// ...fetch from GitHub and persist records...
return { ok: true };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-prs',
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-prs',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
<Note>
`TWENTY_APP_ACCESS_TOKEN` se inyecta automáticamente; consulta [Variables de la aplicación](#application-variables). Dado que las variables de aplicación secretas nunca se exponen a los componentes de front, mantén las claves de API y otra lógica confidencial en la función de lógica, no en el componente de front.
</Note>
### Llamar a la API REST de Twenty
Para llamar a rutas HTTP de la aplicación o leer y escribir registros de Twenty desde un componente de interfaz, utiliza `RestApiClient` de `twenty-client-sdk/rest`. Envía las rutas `/s/...` a la URL base de las funciones de tu espacio de trabajo y cualquier otra ruta, incluidas `/rest/...`, a `TWENTY_API_URL`.
Siempre actúa como la persona que está mirando la página. `runAs: 'application'` es una opción exclusiva de las funciones lógicas: un componente nunca recibe el propio token de tu aplicación, por lo que solicitarlo aquí genera un error. Coloca el trabajo que necesita el acceso propio de la aplicación detrás de una función lógica y llama a esa en su lugar.
| Método | Descripción |
| --------------------------------- | --------------------------------------------------------------------------- |
| `get(path, options?)` | Envía una solicitud `GET` |
| `post(path, body?, options?)` | Envía una solicitud `POST` |
| `put(path, body?, options?)` | Envía una solicitud `PUT` |
| `patch(path, body?, options?)` | Envía una solicitud `PATCH` |
| `delete(path, options?)` | Envía una solicitud `DELETE` |
| `request(method, path, options?)` | Solicitud genérica con cualquier método HTTP |
| `resolveUrl(path, options?)` | Resuelve una ruta a su URL completa sin enviar una solicitud (para enlaces) |
`options` acepta `headers`, `query` (un registro de parámetros de cadena de consulta; los valores nulos o indefinidos se omiten) y un `AbortSignal` mediante `signal`. Un objeto `body` que no sea de tipo `FormData` se serializa automáticamente como JSON. Ante un `401`, el cliente actualiza el token de acceso una vez a través del host y vuelve a intentar la solicitud.
La URL base y el token se resuelven desde el entorno de forma predeterminada. Pasa opciones de sobrescritura al constructor cuando sea necesario — por ejemplo, en pruebas:
```ts
const client = new RestApiClient({
baseUrl: 'https://myworkspace.twenty.com',
token: 'my-token',
});
```
Las solicitudes fallidas lanzan un `RestApiClientError` que expone `status`, `statusText`, `url` y el `body` analizado:
```tsx
import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
const client = new RestApiClient();
try {
const people = await client.get('/rest/people', {
query: { limit: 10 },
});
} catch (error) {
if (error instanceof RestApiClientError) {
console.error(error.status, error.body);
}
}
```
## Acceder al contexto de ejecución
Dentro de tu componente, usa hooks del SDK para acceder al usuario actual, el registro y la instancia del componente:
```tsx src/front-components/record-info.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
useUserId,
useSelectedRecordIds,
useFrontComponentId,
} from 'twenty-sdk/front-component';
const RecordInfo = () => {
const userId = useUserId();
const [recordId] = useSelectedRecordIds();
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,
});
```
Hooks disponibles:
| Hook | Devuelve | Descripción |
| --------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `useUserId()` | `string` o `null` | El ID del usuario actual |
| `useSelectedRecordIds()` | `string[]` | Todos los ID de los registros seleccionados (array vacío si no hay ninguno seleccionado) |
| `useRecordId()` | `string` o `null` | **Obsoleto.** Usa `useSelectedRecordIds()` en su lugar |
| `useFrontComponentId()` | `string` | El ID de esta instancia del componente |
| `useTimelineActivityId()` | `string` o `null` | El ID de actividad de la línea de tiempo actual al representar una fila personalizada de la línea de tiempo |
| `useColorScheme()` | `'light'` o `'dark'` | La combinación de colores activa de la interfaz de usuario del host (`System` ya está resuelto) |
| `useFrontComponentExecutionContext(selector)` | varía | Accede al contexto de ejecución completo con una función selectora |
## Variables de aplicación
Las variables de aplicación definidas en [`defineApplication()`](/l/es/developers/extend/apps/config/application) con `isSecret: false` están disponibles dentro de los componentes de front mediante la utilidad `getApplicationVariable`:
```tsx src/front-components/greeting.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';
const Greeting = () => {
const recipientName = getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World';
return <p>Hello, {recipientName}!</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'greeting',
component: Greeting,
});
```
<Warning>
Las variables secretas (`isSecret: true`) **no** se exponen a los componentes de front. Solo están disponibles en las [funciones de lógica](/l/es/developers/extend/apps/logic/logic-functions), que se ejecutan del lado del servidor. Esto evita que valores confidenciales como las claves de API se envíen al navegador.
</Warning>
`getApplicationVariable` siempre devuelve una **cadena** (o `undefined`), independientemente del `type` declarado de la variable. La cadena se serializa de forma coherente según el tipo (booleanos como `"true"` / `"false"`, números como cadenas decimales, arrays / objetos como JSON), el mismo formato que se usa para la función lógica `process.env` — parsea tú mismo (`Number(...)`, `JSON.parse(...)`, `=== 'true'`). Consulta [Tipos de variables](/l/es/developers/extend/apps/config/application#variable-types).
Las siguientes variables de sistema siempre están disponibles a través de `process.env`:
| Variable | Descripción |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TWENTY_API_URL` | URL base de la API principal de Twenty |
| `TWENTY_APP_ACCESS_TOKEN` | Token de corta duración limitado al rol de la persona que ha iniciado sesión, en intersección con el de tu aplicación, por lo que un componente nunca puede hacer más de lo que puede hacer la persona que lo está mirando |
### `TWENTY_FUNCTIONS_URL`
Twenty también inyecta `TWENTY_FUNCTIONS_URL` en los componentes de interfaz y en las funciones lógicas: la URL base desde la que se sirven las funciones de lógica activadas por HTTP de tu aplicación.
Existe porque esa URL no siempre es el propio servidor de Twenty. En Twenty Cloud, las rutas de la aplicación se sirven en un dominio dedicado por espacio de trabajo (`https://\<your-workspace-subdomain>.withtwenty.com`, o el dominio público principal de la aplicación cuando se configure uno) para que las respuestas definidas por la aplicación se ejecuten en un origen aislado en lugar de en el origen de la aplicación de Twenty. Las instancias autoalojadas y locales sirven las rutas de la aplicación bajo el prefijo `/s` en el propio servidor y es posible que no establezcan la variable en absoluto. Dado que la URL base varía por espacio de trabajo y por instancia, tu código no puede codificarla de forma rígida: el servidor inyecta el valor correcto en tiempo de ejecución.
Rara vez necesitas leerla directamente. Llama a tus rutas a través de `RestApiClient` con una ruta con el prefijo `/s/` y el cliente resuelve la URL por ti: elimina el prefijo `/s` y apunta a `TWENTY_FUNCTIONS_URL`, recurriendo a `\<TWENTY_API_URL>/s` cuando la variable no está establecida. Utiliza `resolveUrl('/s/\<path>')` para obtener la URL absoluta sin enviar una solicitud, por ejemplo, para un enlace. Lee la variable directamente solo cuando construyas una URL manualmente:
```ts
const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
```
## API de comunicación con el host
Los componentes de frontend pueden activar navegación, modales y notificaciones usando funciones de `twenty-sdk`:
| Función | Descripción |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `navigate(to, params?, queryParams?, options?)` | Navegar a una página en la aplicación |
| `openSidePanelPage(params)` | Abrir un panel lateral |
| `closeSidePanel()` | Cerrar el panel lateral |
| `openCommandConfirmationModal(params)` | Mostrar un cuadro de diálogo de confirmación |
| `enqueueSnackbar(params)` | Mostrar una notificación tipo toast |
| `unmountFrontComponent()` | Desmontar el componente |
| `updateProgress(progress)` | Actualizar un indicador de progreso |
| `uploadFile(file, { fieldMetadataId, fileName? })` | Sube un `Blob` a un campo FILES; se resuelve en `{ status: 'uploaded', file: { fileId, path, url, size, mimeType } }` o `{ status: 'failed', reason: 'invalid-params' \| 'upload-failed' }` |
Aquí tienes un ejemplo que usa la API del host para mostrar un snackbar y cerrar el panel lateral después de que una acción finaliza:
```tsx src/front-components/archive-record.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, closeSidePanel, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const ArchiveRecord = () => {
const [recordId] = useSelectedRecordIds();
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,
});
```
### Almacenamiento
`localStorage` y `sessionStorage` funcionan como lo hacen en una página normal, con la API síncrona estándar. Tus claves están limitadas al ámbito de la instalación de tu aplicación y del usuario que ha iniciado sesión: ninguna otra aplicación puede leerlas, y otro usuario que inicie sesión en el mismo navegador comenzará con un almacenamiento vacío. Los valores escritos en `localStorage` permanecen en el dispositivo a través de las recargas; `sessionStorage` dura mientras dure la sesión del navegador.
```tsx src/front-components/note-draft.tsx
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const NoteDraft = () => {
const [draft, setDraft] = useState(() => localStorage.getItem('draft') ?? '');
const handleChange = (nextDraft: string) => {
setDraft(nextDraft);
localStorage.setItem('draft', nextDraft);
};
return (
<textarea value={draft} onChange={(event) => handleChange(event.target.value)} />
);
};
export default defineFrontComponent({
universalIdentifier: 'd0e1f2a3-b4c5-6789-def0-890123456789',
name: 'note-draft',
description: 'Keeps an unsaved note on this device',
component: NoteDraft,
});
```
Twenty almacena los valores en nombre de tu aplicación, por lo que una escritura se aplica localmente de inmediato y se guarda en segundo plano. Las lecturas nunca esperan al host. Nada se sincroniza: los valores no siguen al usuario a otro navegador o máquina, así que usa el [almacenamiento de clave-valor](/l/es/developers/extend/apps/logic/key-value-store) de una función de lógica para cualquier cosa que deba sobrevivir a un cambio de dispositivo.
Las escrituras están limitadas y cada límite cuenta caracteres en lugar de bytes: las claves tienen como máximo 512 caracteres, un solo valor como máximo 262.144 caracteres y cada almacenamiento como máximo 1.048.576 caracteres por aplicación y usuario. Una escritura que excede un límite lanza un `QuotaExceededError`, como la API del navegador.
### Trabajar con varios registros
Usa `useSelectedRecordIds()` para manejar varios registros seleccionados. Esto es útil para operaciones por lotes:
```tsx src/front-components/bulk-export.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const BulkExport = () => {
const selectedRecordIds = useSelectedRecordIds();
const handleExport = async () => {
const client = new CoreApiClient();
for (const recordId of selectedRecordIds) {
await client.mutation({
updateTask: {
__args: { id: recordId, data: { exported: true } },
id: true,
},
});
}
await enqueueSnackbar({
message: `Exported ${selectedRecordIds.length} records`,
variant: 'success',
});
await closeSidePanel();
};
return (
<div style={{ padding: '20px' }}>
<p>Export {selectedRecordIds.length} selected record(s)?</p>
<button onClick={handleExport}>Export</button>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
name: 'bulk-export',
description: 'Export selected records',
component: BulkExport,
});
```
Muéstralo con un [elemento de menú de comando](/l/es/developers/extend/apps/layout/command-menu-items) restringido a selecciones de registros:
```ts src/command-menu-items/bulk-export.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
label: 'Bulk Export',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
});
```
## Recursos públicos
Los componentes de frontend pueden acceder a archivos del directorio `public/` de la aplicación usando `getPublicAssetUrl`:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
export default defineFrontComponent({
universalIdentifier: '...',
name: 'logo',
component: Logo,
});
```
Consulta la [sección de recursos públicos](/l/es/developers/extend/apps/config/public-assets) para más detalles.
## Dependencias compartidas en los componentes frontales
Por defecto, cada componente frontal agrupa su propia copia de las librerías que importa, por lo que una aplicación con cinco componentes viene React cinco veces. Declara las dependencias compartidas en el paquete `package de tu aplicación. son` para construir esas librerías una vez y tener cada componente de la aplicación cargarlas desde un solo archivo caché:
```json package.json
{
"frontComponentSharedDependencies": ["react", "react-dom/client", "twenty-ui/input"]
}
```
Cada componente importa sus dependencias exactamente como antes — nada cambia en su código de componente:
```tsx src/front-components/counter.tsx
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'counter',
component: Counter,
});
```
Algunas cosas que debes saber:
* **Un paquete de dependencias compartidas por aplicación.** El paquete se construye a partir de las propias dependencias de tu aplicación, por lo que conservas el control total de las versiones que envías.
* **Lista los especificadores exactos que importas.** `twenty-ui/input` y `twenty-ui/display` son dos entradas; un nombre de paquete solo no cubre sus subrutas. La lista `react` cubre automáticamente `react/jsx-runtime`.
* **Comparte `react-dom/client` junto a `react`.** Cada componente renderiza a través de `createRoot`, así que dejarlo fuera significa que cada componente todavía incluye React DOM.
* \*\*El paquete está en caché. \* Se sirve bajo una URL de contenido hash con un caché inmutable de larga duración, para que se descargue una vez y se vuelva a utilizar a través de todos los componentes de la aplicación hasta que una de sus dependencias cambie.
* **Los componentes que no importan ninguno de los paquetes compartidos nunca lo descargan.**
## Estilo
Los componentes de frontend admiten varios enfoques de estilos. Puedes usar:
* **Estilos en línea** — `style={{ color: 'red' }}`
* **Componentes de UI de Twenty**: la propia biblioteca de componentes de Twenty; consulta [Uso de los componentes de UI de Twenty](#using-twenty-ui-components) más abajo
* **Emotion** — CSS-in-JS con `@emotion/react`
* **Styled-components** — patrones de `styled.div`
* **Tailwind CSS** — clases utilitarias
* **Cualquier librería CSS-in-JS** compatible con React
## Uso de los componentes de UI de Twenty
Twenty distribuye su biblioteca de componentes como el paquete [`twenty-ui`](https://www.npmjs.com/package/twenty-ui/v/1.0.0-alpha.1). Los componentes de frontend pueden usarlo para botones, etiquetas, pastillas de estado, chips, avatares, iconos, tipografía y tokens de tema que coinciden automáticamente con el tema claro y oscuro del espacio de trabajo.
### Instalación
Añade el paquete a tu aplicación, fijado a la versión con la que se entrega tu instancia de Twenty:
```bash
yarn add twenty-ui@1.0.0-alpha.1
```
`twenty-ui` se incluye en tu componente de frontend en tiempo de compilación, así que solo necesita ser una dependencia de tu aplicación: no hay nada que configurar en tiempo de ejecución.
### Importar componentes
Importa desde la subruta correspondiente en lugar de la raíz del paquete, de modo que solo los componentes que utilizas terminen en tu bundle:
| Subruta | Qué exporta |
| --------------------------- | ----------------------------------------------- |
| `twenty-ui/input` | `Button` e inputs de formulario |
| `twenty-ui/data-display` | `Tag`, `Status`, `Chip`, `Avatar`, y más |
| `twenty-ui/feedback` | `Callout`, `Banner`, `Info`, y más |
| `twenty-ui/typography` | `H1Title`, `H2Title`, `H3Title`, `Label`, y más |
| `twenty-ui/icon` | Componentes `Icon*` (p. ej., `IconCheck`) |
| `twenty-ui/theme-constants` | `ThemeProvider`, `themeCssVariables` |
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Status, Tag } from 'twenty-ui/data-display';
import { Button } from 'twenty-ui/input';
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,
});
```
### Iconos
Importa iconos individuales desde `twenty-ui/icon`:
```tsx
import { IconBox, IconCheck } from 'twenty-ui/icon';
```
Cada icono con nombre se optimiza con tree-shaking, por lo que importar unos pocos añade muy poco a tu bundle. Evita `IconsProvider`, `useIcons` e `iconsState`, ya que incorporan el conjunto completo de iconos Tabler (varios MB).
### Temas y tokens de tema
Los componentes de Twenty UI coinciden automáticamente con el tema claro y oscuro del espacio de trabajo: el renderizador aplica el esquema de color activo en el host y los componentes resuelven sus colores en función de este.
Para usar los mismos tokens de diseño en tus propios estilos en línea, llama al hook `useTheme()`. Devuelve los tokens de tema de Twenty (espaciado, colores, radios, fuentes) conectados al tema activo, sin necesidad de configurar `ThemeProvider` en tu componente:
```tsx
import { useTheme } from 'twenty-ui/theme-constants';
const Card = () => {
const theme = useTheme();
return (
<div
style={{
padding: theme.spacing[4],
background: theme.background.secondary,
color: theme.font.color.primary,
}}
>
Themed card
</div>
);
};
```
Como `useTheme()` es un hook, lees los tokens dentro del cuerpo del componente, por lo que los valores siempre reflejan el tema activo. El mismo mapa de tokens también se exporta como la constante `themeCssVariables`, pero es preferible usar `useTheme()` en los componentes de frontend: una constante a nivel de módulo que desreferencie `themeCssVariables` puede ser indefinida mientras se extrae el manifiesto de la aplicación.
Para hacer bifurcaciones explícitamente según el esquema activo, léelo con `useColorScheme()` de `twenty-sdk/front-component`, que devuelve `'light'` o `'dark'`.
## Limitaciones actuales
Los componentes de Front están en desarrollo activo. El renderizado, el estilo, el manejo de eventos, la medición de elementos y el almacenamiento del navegador funcionan bien. Cualquier cosa que vaya *más allá* de eso (llamar a un método del DOM en un ref, observar cambios de tamaño de elementos, hacer portal fuera de tu árbol) falta o está incompleta hoy, y la mayoría falla silenciosamente: no hay excepción ni error de TypeScript tampoco, ya que el andamiaje está tipado contra el DOM completo del navegador.
Si una de estas cosas te bloquea, [abre una incidencia](https://github.com/twentyhq/twenty/issues/new/choose) para que se priorice.
### Diseño y medición
Los elementos pueden medirse a sí mismos: el host refleja la geometría en el sandbox, de modo que las lecturas se responden localmente pero pueden tener hasta un fotograma de desfase, y la primera lectura de un elemento nunca medido devuelve ceros. Después de escribir, vuelve a leer en un callback de `requestAnimationFrame` o en un efecto.
| API | Qué ocurre |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `getBoundingClientRect()`, `offsetWidth`, `clientWidth`, `scrollWidth`, `offsetTop`, ... | Funcionan, desde el espejo; asignar `scrollTop` / `scrollLeft` no tiene ningún efecto |
| `getClientRects()`, `window.matchMedia()` | Lanza una excepción |
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`, `scrollX`, `scrollY` | Funcionan, informando el **viewport del navegador**; el tamaño propio de tu widget es `document.body.clientWidth` / `clientHeight` |
| `window.getComputedStyle()` | Devuelve solo los estilos en línea del elemento, nunca la cascada calculada del host |
| `ResizeObserver`, `IntersectionObserver` | `ReferenceError` (las comprobaciones con `typeof` sí funcionan) |
| `MutationObserver` | Funciona, incluyendo `subtree`, `attributeFilter`, valores antiguos y `takeRecords()` |
El posicionamiento mediante `getBoundingClientRect` ahora funciona, pero cualquier cosa que observe cambios de tamaño a través de `ResizeObserver` (el `ResponsiveContainer` de recharts, `autoUpdate` de Floating UI) todavía no funciona. Aun así, prefiere usar CSS para el diseño: tu hoja de estilos llega a la página real, por lo que flexbox, grid, `aspect-ratio`, `clamp()` y `@container` se comportan con normalidad, sin desfase de fotogramas.
<Note>
`requestAnimationFrame`, `fetch`, `setTimeout` y `queueMicrotask` funcionan sin el prefijo `window.`. Solo `window.requestAnimationFrame(...)` y similares lanzan una excepción.
</Note>
### Acceso al DOM
Un `ref` te da un elemento sandbox, no un `HTMLElement`.
| Lo que escribes | Qué ocurre | Usa en su lugar |
| ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ref.current.focus()`, `.click()`, `.select()`, `.setSelectionRange()`, `.scrollIntoView()`, `video.play()` | Lanza una excepción | Componentes controlados; lee los valores de `event.target` |
| `element.classList.add(...)` | Lanza una excepción (`classList` es `undefined`) | Construye tú mismo la cadena `className` |
| `document.createTreeWalker()` | Lanza una excepción | `querySelector()` / `querySelectorAll()` y `getElementById()` funcionan; `getElementsByClassName()` también funciona, pero devuelve una colección estática, no una `HTMLCollection` en vivo |
| `document.activeElement` | Siempre `undefined` | Controla el foco con `onFocus` / `onBlur` |
| `canvas` | No renderiza nada, sin error | SVG, o dibujar fuera de pantalla y mostrar el resultado en un elemento `img` |
| `createPortal(node, document.body)` | No renderiza nada, mientras que `isConnected` informa de éxito | Superposiciones en línea con `position: absolute`, o pasa a la biblioteca tu propio elemento contenedor |
La brecha del portal es la razón por la que los popovers de Radix, Headless UI, MUI y react-select no renderizan nada de forma predeterminada. La mayoría acepta una prop de contenedor; apúntala a un elemento que hayas renderizado.
### Eventos
Eventos de ratón, puntero, tacto, arrastre, teclado, foco, `input`/`change`/`submit`, `scroll`/`wheel`/`contextmenu` y `animationend`/`transitionend` pasan al host, además de algunos específicos por elemento: `load`/`error` en `img`, portapapeles y composición en `input`/`textarea`, medios en `video`/`audio`, `toggle` en `details`/`dialog`. Cualquier otra cosa (`onAuxClick`, `onSelect`, `onInvalid`, `onReset`, `onAnimationStart`, captura de puntero, `onLoad` fuera de `img`) se descarta sin aviso.
`document.addEventListener()` y `window.addEventListener()` se registran sin error y nunca se disparan, lo que explica por qué un arrastre se detiene tan pronto como el puntero sale del elemento en el que comenzó. `event.preventDefault()` tampoco cruza; el envío de formularios, `dragover`/`drop` y los clics en enlaces ya están protegidos por ti.
### Atributos y estilos
Cada elemento reenvía sus propias propiedades al DOM del host (`href` en `a`, `src`/`alt` en `img`, `value`/`placeholder`/`disabled` en `input`, etc.), además de un conjunto común en cada elemento: `id`, `className`, `style`, `title`, `tabIndex`, `role`, `draggable` y cualquier atributo `aria-*` / `data-*` (con guiones, por lo que `ariaLabel` se descarta). Cualquier cosa fuera de eso se descarta silenciosamente, así que expresa el estado personalizado como `data-*`.
El CSS del componente, ya sea desde `import './styles.css'`, CSS-in-JS o un elemento `style`, se inyecta en el `head` de la página del host **sin ámbito**. Así que los nombres de clase colisionan con los de Twenty (ponles un prefijo y nunca escribas `div { ... }` como selectores), y `@media` coincide con la ventana del navegador en lugar de con tu widget (usa `@container` con tu propio `container-type`). Las props de `style` en línea no se ven afectadas.
### Almacenamiento y red
`localStorage` y `sessionStorage` son proporcionados por Twenty en lugar del navegador: el componente se ejecuta en un worker en un origen opaco, por lo que el host almacena los valores en nombre de tu aplicación. Consulta [almacenamiento](#storage) para ver su ámbito y límites. IndexedDB, las cookies, la Cache API y `BroadcastChannel` siguen sin estar disponibles. Para mantener el estado entre dispositivos, llama a una [función de lógica](/l/es/developers/extend/apps/logic/logic-functions) y usa su [almacenamiento de clave-valor](/l/es/developers/extend/apps/logic/key-value-store).
`fetch` funciona, con matices:
* Las llamadas a la API de Twenty y a las rutas de tu aplicación se hacen por proxy a través del host, así que da prioridad a [`RestApiClient`](#calling-the-twenty-rest-api). En las llamadas con proxy, `AbortSignal` y las demás opciones de `RequestInit` se descartan, y solo se admiten cuerpos de tipo `string` y `URLSearchParams`.
* Otros orígenes salen del sandbox con `Origin: null`, por lo que una API de terceros responde solo si envía `Access-Control-Allow-Origin: *`. Llámala desde una función de lógica en su lugar.
* `fetch('/rest/people')` nunca se hace coincidir con la API de Twenty, porque el sandbox no tiene URL de página contra la que resolver una ruta relativa.
### Captura de medios
`navigator.mediaDevices.getUserMedia()` y `MediaRecorder` funcionan dentro de los front components mediante polyfills de sandbox, por lo que el código estándar de grabación se ejecuta sin cambios y `MediaRecorder.isTypeSupported` indica si se admite para combinaciones habituales de contenedor/códec. Se aceptan objetos de restricciones detalladas de `getUserMedia`, pero no se reenvían: el host captura con sus valores predeterminados para los tipos solicitados, y solo una captura puede estar activa a la vez entre aplicaciones. Almacena un `Blob` grabado con la función host `uploadFile`.
### Otras carencias
* **Contenido de archivos.** Un `input` de tipo `file` solo proporciona a tu manejador los metadatos del archivo, no los bytes, por lo que `FileReader` no está disponible. Para subir un `Blob` que tu código ya tiene — por ejemplo, uno producido por `MediaRecorder` — usa la función host `uploadFile`.
* **Cargas útiles de arrastrar y soltar.** Los eventos de arrastre se disparan, pero `event.dataTransfer` es `undefined`.
* **Integraciones nativas de Node.** `fs`, `path` y `node:crypto` hacen que la compilación falle, así que mueve ese trabajo a una [función de lógica](/l/es/developers/extend/apps/logic/logic-functions). Web Crypto, `fetch`, `TextEncoder` y `URL` están disponibles.
* **`iframe`** siempre se vuelve a aislar sin `allow-same-origin`, por lo que un embed que depende de su propia sesión se renderiza como desconectado. Tampoco tiene `onLoad`.

View File

@@ -0,0 +1,46 @@
---
title: Elementos del menú de navegación
description: Agrega entradas personalizadas a la barra lateral del espacio de trabajo — enlaces a vistas guardadas o URLs externas.
icon: bars
---
Un **elemento del menú de navegación** es una entrada en la barra lateral izquierda. Usa `defineNavigationMenuItem()` para distribuir enlaces personalizados en la barra lateral — normalmente uno por cada [vista](/l/es/developers/extend/apps/layout/views) que publiques — o para apuntar a URL externas.
```ts src/navigation-menu-items/example-navigation-menu-item.ts
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
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,
});
```
## Puntos clave
* `type` determina a qué enlaza el elemento del menú. Cada tipo se asocia con un campo identificador específico:
| Tipo | Qué hace | Campo obligatorio |
| ------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `NavigationMenuItemType.VIEW` | Abre una vista guardada | `viewUniversalIdentifier` |
| `NavigationMenuItemType.LINK` | Abre una URL externa | `link` |
| `NavigationMenuItemType.FOLDER` | Agrupa elementos anidados bajo una etiqueta | `name` (y los elementos secundarios hacen referencia a la carpeta mediante `folderUniversalIdentifier`) |
| `NavigationMenuItemType.OBJECT` | Abre la página de índice predeterminada de un objeto | `targetObjectUniversalIdentifier` |
| `NavigationMenuItemType.PAGE_LAYOUT` | Abre un diseño de página independiente | `pageLayoutUniversalIdentifier` |
* `position` controla el orden en la barra lateral.
* El enum también contiene `NavigationMenuItemType.RECORD`, que se usa internamente para los favoritos de registros creados por el usuario; no se puede usar desde un manifiesto de aplicación (no hay ningún campo para hacer referencia a un registro).
* `icon` y `color` son opcionales y personalizan el aspecto de la entrada.
* `folderUniversalIdentifier` también está disponible en cualquier elemento para anidarlo dentro de un elemento padre de tipo `FOLDER`.
<Note>
**Error común:** crear un objeto sin un elemento de menú de navegación asociado hace que ese objeto sea inaccesible desde la barra lateral. A menos que sea un objeto técnico/interno, cada objeto personalizado debería tener una entrada en la barra lateral que apunte a él.
</Note>

View File

@@ -0,0 +1,59 @@
---
title: Resumen
description: Coloca tu aplicación dentro de la interfaz de usuario de Twenty — entradas de la barra lateral, vistas guardadas, pestañas de la página de registro y componentes React aislados.
icon: table-columns
---
La **capa de diseño** de una aplicación de Twenty es todo lo que el usuario ve: dónde aparece la aplicación en la barra lateral, qué vistas de lista incluye, cómo se organizan sus páginas de detalles de registro y qué componentes React personalizados se renderizan dentro de esas páginas.
```text
Sidebar Record list Record detail page
─────── ─────────── ──────────────────
[📋 My View] ────▶ ┌──────────┐ ┌─────────────────────┐
[📋 Drafts ] │ Companies│ │ Tabs: [Overview ] │
[📋 Inbox ] │ ──────── │ │ [Notes ] │
▲ │ Apple │ │ [Hello ]◀──── definePageLayoutTab
│ │ Acme │ │ │ adds a tab...
└ defineNavi- │ … │ │ ┌────────────────┐ │
gationMenu- └────▲─────┘ │ │ │ │
Item points │ │ │ React UI │◀── …with a
to a defineView │ │ │ (sandboxed in │ │ defineFrontComponent
└ defineView │ │ a Worker) │ │ widget inside
picks columns │ └────────────────┘ │
and filters └─────────────────────┘
```
## En esta sección
<CardGroup cols={2}>
<Card title="Vistas" icon="list" href="/l/es/developers/extend/apps/layout/views">
`defineView` — configuraciones de listas guardadas: columnas visibles, filtros, grupos.
</Card>
<Card title="Elementos del menú de navegación" icon="bars" href="/l/es/developers/extend/apps/layout/navigation-menu-items">
`defineNavigationMenuItem` — entradas de la barra lateral que apuntan a vistas o URL externas.
</Card>
<Card title="Diseños de Página" icon="table-columns" href="/l/es/developers/extend/apps/layout/page-layouts">
`definePageLayout` y `definePageLayoutTab` — pestañas y widgets en la página de detalles de un registro.
</Card>
<Card title="Componentes de frontend" icon="window-maximize" href="/l/es/developers/extend/apps/layout/front-components">
`defineFrontComponent` — componentes React aislados que se renderizan dentro de Twenty.
</Card>
<Card title="Elementos del menú de comandos" icon="terminal" href="/l/es/developers/extend/apps/layout/command-menu-items">
`defineCommandMenuItem` — registra componentes de frontend como entradas Cmd+K y acciones rápidas.
</Card>
</CardGroup>
## Dónde aparece la aplicación
| Ubicación | Qué controla | Entidad |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| **Barra lateral** | Una entrada personalizada que enlaza a una vista guardada o a una URL externa | `defineNavigationMenuItem` |
| **Lista de registros** | Una configuración guardada para un objeto — columnas visibles, orden, filtros, grupos | `defineView` |
| **Página de detalles del registro** | Las pestañas y widgets en una página de registro (de tu propio objeto o de uno estándar) | `definePageLayout`, `definePageLayoutTab` |
| **Dentro de cualquiera de las anteriores** | Un widget React personalizado — botones, formularios, paneles, integraciones | `defineFrontComponent` |
| **Configuración de la app** | Una sección de configuración personalizada dentro de la pestaña Configuración de la app, en lugar de la interfaz predeterminada de variables. | `defineSettingsFrontComponent` |
| **Menú de comandos (Cmd+K)** | Una acción rápida fijada o un comando oculto | `defineCommandMenuItem` |
Los componentes de frontend se ejecutan dentro de un Web Worker aislado usando Remote DOM — se renderizan de forma nativa en la página (no dentro de un iframe), pero no pueden acceder directamente a la página o al DOM del host. La comunicación con Twenty ocurre a través de una API de host de paso de mensajes.
Todavía están en desarrollo activo: el sandbox implementa un DOM parcial, por lo que los usos avanzados pueden fallar. Consulta [Limitaciones actuales](/l/es/developers/extend/apps/layout/front-components#current-limitations).

View File

@@ -0,0 +1,158 @@
---
title: Diseños de Página
description: Personaliza las páginas de detalle de los registros — pestañas, widgets y dónde se renderizan los componentes de frontend — usando `definePageLayout` y `definePageLayoutTab`.
icon: table-columns
---
Un **page layout** controla cómo se organiza la página de detalle de un registro: qué pestañas aparecen y qué widgets contienen. Usa `definePageLayout()` para declarar un layout para un objeto que posees, o `definePageLayoutTab()` para agregar una sola pestaña a un layout que ya existe (tuyo o uno estándar de Twenty).
| Caso de uso | Entidad |
| -------------------------------------------------------------------------- | --------------------- |
| Define todo el layout para una página de registro en un objeto que posees | `definePageLayout` |
| Agrega una pestaña a un layout existente (tu propio objeto o uno estándar) | `definePageLayoutTab` |
## definePageLayout
Usa esto cuando eres propietario de toda la página de detalle; normalmente para un objeto personalizado que definiste tú mismo.
```ts src/page-layouts/example-record-page-layout.ts
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
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.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
```
### Puntos clave
* `type` es uno de `'RECORD_INDEX'`, `'RECORD_PAGE'`, `'DASHBOARD'` o `'STANDALONE_PAGE'`. Use `'RECORD_PAGE'` para personalizar la vista de detalles de un objeto específico.
* `objectUniversalIdentifier` especifica a qué objeto se aplica este diseño.
* Cada `tab` define una sección de la página con un `title`, `position` y `layoutMode`: `VERTICAL_LIST` para páginas de registros y páginas independientes, `GRID` para paneles y `CANVAS` para un único widget que debe ocupar el área de visualización de la pestaña. Una pestaña `VERTICAL_LIST` apila los widgets verticalmente. Los widgets integrados que gestionan su propio desplazamiento, como las líneas de tiempo, los archivos, las notas, las tareas y los flujos de trabajo, ocupan un área de visualización; los campos, los componentes front, los gráficos y otros widgets de ajuste al contenido se representan a la altura de su contenido o a la altura configurada. Una pestaña `GRID` siempre distribuye sus widgets como tarjetas en una cuadrícula de 12 columnas. Un widget `CANVAS` no tiene una posición explícita; si una pestaña de lienzo contiene varios widgets, se representan a la altura de su contenido en lugar de llenar la ventana gráfica.
* Establece `layoutMode` explícitamente. Omitirlo te da `VERTICAL_LIST` en una `STANDALONE_PAGE` y `GRID` en todos los demás casos, lo cual rara vez es lo que quieres en una página de registro.
* Cada `widget` dentro de una pestaña puede renderizar un [componente de frontend](/l/es/developers/extend/apps/layout/front-components), una lista de relaciones u otros tipos de widget integrados.
* Un widget `FRONT_COMPONENT` puede establecer `headerCommandMenuItemUniversalIdentifiers` en una matriz ordenada de identificadores universales de elementos de menú de comandos de la misma aplicación. Estas acciones aparecen como botones de icono en el encabezado de la tarjeta del widget y conservan sus comprobaciones de disponibilidad y permisos a nivel de comando. Los identificadores deben ser únicos y deben resolverse cuando se instala la aplicación.
* `position` en las pestañas controla su orden. Usa valores más altos (p. ej., 50) para colocar pestañas personalizadas después de las integradas.
### Widgets de campos
Un widget `FIELD` representa un campo del registro. Para los campos de relación, también puede incrustar una lista de registros relacionados:
```ts
{
universalIdentifier: 'c1c2c3c4-c5c6-4000-8000-000000000003',
title: 'People → Opportunities',
type: 'FIELD',
configuration: {
configurationType: 'FIELD',
fieldMetadataId: PEOPLE_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'TABLE',
nestedRelationFieldMetadataId: OPPORTUNITIES_FIELD_UNIVERSAL_IDENTIFIER,
},
}
```
* `fieldMetadataId` toma el identificador universal de un campo en el objeto del layout.
* `fieldDisplayMode` es uno de `'FIELD'`, `'CARD'`, `'EDITOR'`, `'VIEW'` o `'TABLE'`. `TABLE` incrusta una vista que lista los registros de un campo de relación de uno a muchos.
* `nestedRelationFieldMetadataId` es opcional y toma el identificador universal de un campo de relación de uno a muchos en el objeto de destino de la relación, para enumerar registros a dos saltos de relación (por ejemplo, una página de Company que enumera las oportunidades de las personas de la compañía, o una página de Person que enumera las oportunidades de la compañía de la persona). El primer salto puede ser un campo de relación de uno a muchos o de muchos a uno, el segundo debe ser de uno a muchos (las relaciones de unión no son compatibles) y requiere `fieldDisplayMode: 'TABLE'` — combinarlo con cualquier otro modo de visualización es un error de validación, ya que un widget anidado siempre se representa como una vista incrustada.
## definePageLayoutTab
Usa esto cuando solo quieras **agregar** una pestaña a un layout existente; por ejemplo, una pestaña de analíticas en la página estándar de Company o una pestaña de resumen de IA añadida al layout de tu propio objeto.
```ts src/page-layouts/example-extra-tab.ts
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
});
```
### Puntos clave
* `pageLayoutUniversalIdentifier` es **obligatorio** y debe apuntar a un page layout que ya exista en el momento de la instalación, ya sea un layout estándar de Twenty o uno definido por tu propia app. Las referencias entre apps a layouts que pertenecen a otra app instalada no son compatibles hoy en día. Cuando falta el layout padre, la instalación falla con un error de validación claro.
* Para los diseños estándar de Twenty, importa los identificadores desde `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
Cada entrada de diseño también expone sus `tabs` y sus `widgets`, para que puedas hacer referencia a cualquier nivel:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home
.universalIdentifier;
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets
.fields.universalIdentifier;
```
También hay disponible un alias corto `STANDARD_PAGE_LAYOUT`:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
* `widgets` están limitados solo a esta pestaña: hacen referencia a [componentes de frontend](/l/es/developers/extend/apps/layout/front-components), vistas, etc., exactamente igual que los widgets definidos en línea en `definePageLayout`.
* `position` controla el orden con respecto a las pestañas existentes en el diseño de página de destino. Elige un valor que sitúe tu pestaña donde la quieras, en relación con las pestañas integradas.
* Usa esto en lugar de `definePageLayout` cuando solo quieras agregar a un layout existente. Usa `definePageLayout` cuando eres propietario de todo el layout.

View File

@@ -0,0 +1,113 @@
---
title: Vistas
description: Incluye vistas guardadas preconfiguradas — orden de columnas, filtros y grupos — para los objetos de tu aplicación.
icon: list
---
Una **vista** es una configuración guardada de cómo se muestran los registros de un objeto: qué campos aparecen, su orden, si son visibles y qué filtros o grupos se aplican. Usa `defineView()` para incluir vistas preconfiguradas con tu aplicación.
Cada objeto obtiene automáticamente su vista principal de lista (la tabla "All ..." que abre un elemento de navegación `OBJECT`) aprovisionada por el servidor, incluidos los objetos que crea tu aplicación, con una columna por cada campo que declara tu aplicación. Las vistas que defines con `defineView()` son siempre vistas **adicionales** sobre esa vista predeterminada.
```ts src/views/example-view.ts
import { defineView } from 'twenty-sdk/define';
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',
position: 0,
fields: [
{
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
```
## Puntos clave
* `objectUniversalIdentifier` especifica a qué objeto se aplica esta vista. Puede ser un objeto personalizado que hayas definido o un objeto estándar de Twenty.
* La vista principal de lista del objeto pertenece al servidor: `key` está en desuso y se ignora, por lo que una vista de manifiesto nunca puede reclamarla. Incluye un elemento de navegación `VIEW` si quieres que tu vista esté a un clic de distancia en la barra lateral.
* `fields` controla qué columnas aparecen y en qué orden. Cada campo referencia un `fieldMetadataUniversalIdentifier`. Para hacer referencia a un campo del sistema creado automáticamente, como `createdAt`, consulta [Referencia a metadatos del sistema](/l/es/developers/extend/apps/data/system-fields).
* También puedes definir `filters`, `filterGroups`, `sorts`, `groups` y `fieldGroups` para configuraciones avanzadas.
* `position` controla el orden cuando existen múltiples vistas para el mismo objeto.
## Propiedades opcionales
| Propiedad | Valores | Descripción |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `ViewType.TABLE` (predeterminado), `ViewType.LIST`, `ViewType.KANBAN`, `ViewType.CALENDAR` | Cómo se presentan los registros. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, `LIST_WIDGET` y `CALENDAR_WIDGET` también existen, pero son usados internamente por los widgets de diseño de página). |
| `visibility` | `ViewVisibility.WORKSPACE` (predeterminado), `ViewVisibility.UNLISTED` | Si la vista se muestra para todo el espacio de trabajo o se oculta en los selectores. |
| `openRecordIn` | obsoleto | Ya no se lee: dónde se abren los registros ahora es una propiedad del [objeto](/l/es/developers/extend/apps/data/objects) (`openRecordIn` en `defineObject()`), y si no se define, se recurre a la preferencia de cada miembro. |
| `criterios de ordenación` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | Orden de clasificación predeterminado. |
| `isCompact` | `boolean` | Visualización compacta de filas. |
| `mainGroupByFieldMetadataUniversalIdentifier` + `shouldHideEmptyGroups` | — | Agrupar registros (por ejemplo, columnas de kanban) por un campo. |
| `kanbanAggregateOperation`, `kanbanAggregateOperationFieldMetadataUniversalIdentifier`, `kanbanColumnWidth` | `AggregateOperations.*` | Agregados y tamaño de columnas kanban. |
| `calendarLayout`, `calendarFieldMetadataUniversalIdentifier`, `calendarEndFieldMetadataUniversalIdentifier` | `ViewCalendarLayout.DAY` / `WEEK` / `MONTH` | Vistas de calendario: diseño, el campo de fecha que posiciona los registros y un campo de fecha de finalización opcional. |
Todos los enums anteriores se exportan desde `twenty-sdk/define`.
## Filtros
Una vista puede incluir filtros preaplicados. Cada filtro tiene tres coordenadas: el **campo** que se está filtrando, el **operando** (cómo comparar) y el **valor** (contra qué comparar). Las tres deben alinearse: usar un operando que no aplique a un tipo de campo será rechazado en el momento de la sincronización.
```ts
import { ViewFilterOperand } from 'twenty-sdk/define';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### Operandos admitidos por tipo de campo
| Tipo de campo | Operandos admitidos |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Los tipos de campo con nombres similares pueden usar operandos completamente diferentes; `SELECT` y `MULTI_SELECT` son un caso común.
### Forma del valor por operando
El campo `value` siempre es un valor serializable en JSON, pero su forma esperada depende del operando:
| Familia de operandos | Forma del valor | Ejemplo |
| ------------------------------------------------------ | ------------------------------------- | ------------------------ |
| `IS`, `IS_NOT` en `SELECT` | array de claves de opciones (cadenas) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` en `MULTI_SELECT` | array de claves de opciones (cadenas) | `['TAG_A']` |
| `IS`, `IS_NOT` en `RELATION` | array de IDs de registros (uuids) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` en campos de tipo texto | cadena | `'acme'` |
| `IS`, `IS_NOT` en `NUMBER` | cadena (el valor) | `'5'` |
| `IS` en `RATING` / `UUID` | cadena (el valor) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | cadena (el límite) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` en `DATE` / `DATE_TIME` | cadena ISO 8601 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | cadena vacía | `''` |
| `IS` en `BOOLEAN` | `'true'` o `'false'` | `'true'` |
## Cómo aparecen las vistas en la interfaz de usuario
Una vista por sí sola no es accesible desde la barra lateral. Para que aparezca allí, vincúlala con un [elemento del menú de navegación](/l/es/developers/extend/apps/layout/navigation-menu-items) de tipo `VIEW` que apunte al `universalIdentifier` de la vista. Ese es el patrón canónico: cada objeto personalizado obtiene una vista de índice predeterminada autogenerada al crearse, por lo que solo necesitas incluir la entrada de la barra lateral que la abre.

View File

@@ -0,0 +1,197 @@
---
title: Trabajos en segundo plano
description: Puede delegar el trabajo prolongado o sujeto a limitaciones de tasa a los workers de Twenty encolando otra ejecución de una función lógica en lugar de hacerlo todo en el mismo flujo.
icon: layer-group
---
La ejecución de una función lógica está limitada por su `timeoutSeconds` (900 segundos como máximo). Cualquier cosa que no pueda terminar en esa ventana — una resincronización completa, una distribución por registro, una API de terceros que impone límites de tasa — debe dividirse en ejecuciones más pequeñas.
`enqueueJobs` hace exactamente eso: solicita a los workers de Twenty que ejecuten más tarde una de las funciones lógicas de tu aplicación, una vez por carga útil, cada ejecución en su propio proceso con su propio presupuesto de tiempo de espera. La llamada devuelve de inmediato.
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## Poner en cola ejecuciones
Importa `enqueueJobs` desde `twenty-sdk/logic-function`, apúntalo al `universalIdentifier` de la función lógica que se ejecutará y pasa una carga útil por ejecución para poner en cola.
```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 }],
});
```
Cada ejecución recibe su carga útil como argumento del manejador, exactamente igual que cualquier otro disparador. El destino debe pertenecer a la **misma aplicación** que quien realiza la llamada; poner en cola la función de otra aplicación se rechaza con `Logic function not found` y no se pone nada en cola. Una sola llamada acepta hasta `200` cargas útiles.
<Note>
`enqueueJobs` devuelve tan pronto como se aceptan los trabajos, no cuando se han ejecutado. No devuelve los resultados de los destinos: haz que cada destino escriba lo que produce en el [almacenamiento de pares clave-valor](/l/es/developers/extend/apps/logic/key-value-store) o en un registro del espacio de trabajo si necesitas leerlo después.
</Note>
<Note>
El auxiliar `enqueueJob` más antiguo, que pone en cola un único trabajo por llamada, ha quedado obsoleto. Usa `enqueueJobs` con una lista `payloads` de un elemento en su lugar.
</Note>
## Opciones del trabajo
Las opciones se aplican a todas las ejecuciones del lote.
| Opción | Predeterminado | Rango | Qué hace |
| ------------ | -------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `retryLimit` | `0` | `0`–`10` | Intentos adicionales totales en la cola. Los reintentos solicitados por la aplicación tienen un límite de `3`. Solo incrementa este valor para manejadores que se puedan ejecutar dos veces de forma segura. |
| `delayMs` | `0` | `0`–`604800000` (7 días) | Espera este tiempo antes de que las ejecuciones sean elegibles. |
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**La prioridad todavía no es configurable.** Los trabajos encolados siempre se ejecutan con la prioridad más baja, para que el trabajo de la plataforma nunca se retrase por detrás de los trabajos de las aplicaciones. El control sobre la prioridad llegará pronto.
</Note>
La ejecución encolada hereda el usuario activo de la función que la puso en cola, por lo que actúa con los mismos permisos.
## Reintentar un fallo transitorio
Twenty no vuelve a intentar todas las excepciones del código de la aplicación. Un error ordinario lanzado se trata como un fallo permanente. Para un fallo transitorio, una función lógica en cola puede solicitar hasta tres reintentos lanzando `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}`,
);
}
};
```
Lanza `RetryableLogicFunctionError` directamente cuando sea posible. Si lo extiendes, no sustituyas su `name`: Twenty reconoce el nombre serializado `RetryableLogicFunctionError` en todos los entornos de ejecución.
`retryCount` es `0` para la ejecución inicial y aumenta solo cuando el código de la aplicación solicita un reintento. `maxRetries` es como máximo `3` y puede ser menor cuando el trabajo en cola tiene un límite general de reintentos más bajo. Los fallos de la plataforma no aumentan `retryCount`, aunque siguen consumiendo el presupuesto general de seguridad de la cola.
La cola retrasa los intentos de reintento con una espera exponencial y fluctuación. El retraso exacto no se garantiza intencionadamente, por lo que el código de la aplicación no debe depender de que un reintento ocurra en un momento preciso. Una vez que se alcanza `maxRetries`, otro `RetryableLogicFunctionError` se registra como el fallo final de la aplicación sin otra ejecución.
<Warning>
Los reintentos vuelven a ejecutar todo el controlador y pueden ocurrir después de que algunos efectos secundarios se hayan realizado correctamente. Haz que el controlador sea idempotente antes de solicitar reintentos.
</Warning>
## Úsalo: recorre por páginas una sincronización larga
La forma clásica es una función que se pone en cola *a sí misma* con el cursor siguiente. Cada ejecución hace una página de trabajo bien dentro de su propio tiempo de espera, y la cadena se detiene cuando no queda nada.
```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,
});
```
## Dividir por registro
Cuando el trabajo es de forma natural por elemento, pon en cola un trabajo por elemento en una sola llamada y deja que los workers los procesen en paralelo en lugar de iterar en línea.
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payloads: companies.map((company) => ({ companyId: company.id })),
retryLimit: 2,
});
```
## Buenas prácticas para trabajos de larga duración
Dos reglas cubren casi cualquier trabajo largo: **usa recursión en lugar de bucles** y **procesa un bloque acotado por ejecución**.
Una ejecución que intenta hacerlo todo es la forma en que falla: alcanza el tiempo de espera y, con un reintento, vuelve a empezar todo desde cero. En su lugar, dimensiona un bloque para que termine con holgura dentro de `timeoutSeconds`, guarda tu posición y pon en cola la siguiente ejecución.
```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,
});
```
Por qué esto se mantiene bien:
* **Dimensiona el bloque a partir del elemento más lento, no del promedio.** `CHUNK_SIZE × worst-case item time` tiene que caber en `timeoutSeconds` con margen de sobra, o la parte final de un bloque se pierde cuando se corta la ejecución.
* **Haz que la condición de terminación sea explícita.** Haz recursión solo mientras haya vuelto un bloque completo. Una cadena que se detiene solo con "sin resultados" seguirá ejecutándose para siempre si la fuente alguna vez devuelve una página corta a mitad de camino.
* **Guarda el progreso antes de poner en cola la siguiente ejecución**, de modo que un eslabón fallido se reinicie desde el último bloque completado en lugar de desde el principio.
* **Mantén cada bloque idempotente.** Volver a procesar un bloque después de un reintento no debe escribir dos veces — indexa las escrituras por el registro o el id externo que estés procesando.
* **Prefiere una cadena por bloques en lugar de una expansión masiva** cuando el trabajo interactúa con un tercero con limitación de tasa: una cadena con `delayMs` se autorregula, mientras que miles de trabajos encolados a la vez se vuelven elegibles de inmediato.

View File

@@ -0,0 +1,269 @@
---
title: Conexiones
description: Permite que tu aplicación actúe en nombre de un usuario en servicios de terceros mediante OAuth.
icon: plug
---
Las conexiones son credenciales que un usuario posee para un servicio externo (Linear, GitHub, Slack, ...). Tu aplicación declara **cómo** se obtienen esas credenciales — un **proveedor de conexión** — y las consume en tiempo de ejecución para realizar llamadas autenticadas a la API de terceros.
Actualmente solo se admite OAuth 2.0. Los futuros tipos de credenciales (tokens de acceso personal, claves de API, autenticación básica) se integrarán en la misma interfaz — las aplicaciones que ya usan `defineConnectionProvider({ type: 'oauth', ... })` no necesitarán migrar.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="Declara cómo se obtienen las conexiones de tu aplicación">
Un proveedor de conexión describe el flujo de OAuth que tu aplicación necesita. El usuario hace clic en "Agregar conexión" en la configuración de tu aplicación, completa la pantalla de consentimiento del proveedor y se crea una fila `ConnectedAccount` en su espacio de trabajo.
Una configuración funcional necesita **dos archivos** — el proveedor de conexión y una declaración `serverVariables` correspondiente en `defineApplication` que contiene las credenciales del cliente 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,
},
},
});
```
Puntos clave:
* `name` es la cadena de identificador única utilizada en `listConnections({ providerName })` (kebab-case, debe coincidir con `^[a-z][a-z0-9-]*$`).
* `displayName` se muestra en la pestaña de configuración por aplicación y en la lista de herramientas de IA.
* `clientIdVariable` / `clientSecretVariable` son **nombres**, no valores — deben coincidir con las claves declaradas en `defineApplication.serverVariables`. Los `client_id` y `client_secret` reales los introduce el administrador del servidor a través de la interfaz de registro de la aplicación; nunca se incluyen en tu repositorio.
* Usa `serverVariables` (no `applicationVariables`) — las credenciales de OAuth son a nivel de servidor y hay una aplicación OAuth por servidor de Twenty.
* Hasta que ambos `serverVariables` estén completos, la pestaña de configuración por aplicación muestra un aviso de "requiere administrador del servidor" y el botón "Agregar conexión" está deshabilitado.
* `type: 'oauth'` es el único valor admitido actualmente. El discriminador es compatible hacia adelante: tipos futuros (`'pat'`, `'api-key'`, ...) agregarán nuevos bloques de subconfiguración junto a `oauth`.
La URL de callback de OAuth que tu proveedor debe autorizar es:
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="Ejecutar una función de lógica al conectar" description="Reaccionar en el momento en que se establece una conexión">
Algunos proveedores te entregan datos en el momento de la conexión que necesitas conservar antes de que la conexión sea utilizable: el ejemplo clásico es Slack, donde la respuesta de OAuth identifica el `team_id` del espacio de trabajo con el que se asociarán los eventos entrantes. Establece `onConnectLogicFunction` para que haga referencia a una función de lógica en la misma aplicación (por su `universalIdentifier`), y se ejecutará justo después de que se cree el `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',
},
});
```
El hook se ejecuta **de forma asíncrona en el espacio de trabajo que se está conectando** (se pone en cola, no se espera su finalización), por lo que un hook lento o con fallos nunca bloquea ni interrumpe el callback de OAuth; hazlo idempotente y haz que gestione sus propios reintentos. El controlador recibe:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
A partir de ahí, usa `getConnection(connectedAccountId)` para leer el token de acceso reciente y llamar a la API del proveedor (por ejemplo, Slack `auth.test`) o para conservar un mapeo con el [almacenamiento de pares clave-valor](/l/es/developers/extend/apps/logic/key-value-store).
</Accordion>
<Accordion title="Ejecutar una función de lógica al desconectar" description="Limpieza cuando se elimina una conexión">
Todo lo que una aplicación reclame en el momento de la conexión debe liberarse cuando la conexión se termina. Una integración de Slack que reclame un `team_id` al conectarse, por ejemplo, debe liberar esa reserva para que otro espacio de trabajo pueda conectar ese mismo espacio de trabajo de Slack. Establece `onDisconnectLogicFunction` para que haga referencia a una función de lógica en la misma aplicación, y se ejecutará justo después de que se elimine el `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',
},
});
```
Al igual que el hook de conexión, se ejecuta **de forma asíncrona en el espacio de trabajo que se está desconectando** y nunca bloquea la desconexión. El controlador recibe la misma estructura de payload:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
El `ConnectedAccount` ya ha desaparecido cuando se ejecuta el hook, por lo que `getConnection(connectedAccountId)` ya no se resuelve. Cualquier cosa que la limpieza necesite (un `team_id`, un id de suscripción externa) debe haberse escrito en el [almacén de claves y valores](/l/es/developers/extend/apps/logic/key-value-store) en el momento de la conexión, usando `connectedAccountId` como clave.
El hook se dispara cuando una conexión se elimina por sí sola. Desinstalar la app elimina sus conexiones mediante una cascada de base de datos, por lo que el hook no se ejecuta en ese caso. Declara una `uninstallLogicFunction` en `defineApplication` para esa ruta: se ejecuta antes de que se eliminen los metadatos de la app, por lo que todavía puede llamar a `listConnections` y limpiar lo que quede.
</Accordion>
<Accordion title="listConnections / getConnection" description="Usa conexiones desde una función de lógica">
Dentro de un controlador de función de lógica, `listConnections({ providerName })` devuelve las filas `ConnectedAccount` de esta aplicación para el proveedor indicado, con tokens de acceso actualizados.
```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 };
};
```
Cada conexión tiene:
| Campo | Descripción |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | ID de fila único; pásalo a `getConnection(id)` para volver a obtener una sola conexión |
| `visibility` | `'user'` (privada para un miembro del espacio de trabajo) o `'workspace'` (compartida con todos los miembros) |
| `scopes` | Permisos de OAuth concedidos por el proveedor de origen (distintos de `visibility` — no están relacionados) |
| `userWorkspaceId` | El id de userWorkspace del propietario — útil para elegir "la conexión del usuario de la solicitud" en activadores de rutas HTTP |
| `accessToken` | Token de acceso OAuth actualizado (se renueva automáticamente si ha expirado) |
| `nombre` | El nombre para mostrar de la conexión (derivado automáticamente en el callback de OAuth, el usuario puede cambiarlo) |
| `handle` | El correo electrónico de la cuenta principal conectada, se actualizó en cada reconecto; resuelto desde el OIDC `id_token` devuelto en intercambio de tokens, volviendo a la conexión de correo electrónico del usuario Veinte cuando el proveedor no devuelve uno |
| `authFailedAt` | Se establece cuando la actualización más reciente falló; el usuario debe reconectarse |
Puntos clave:
* Pasa `{ providerName }` para filtrar por proveedor; omítelo para obtener todas las conexiones que posee esta aplicación en todos los proveedores.
* El servidor actualiza de forma transparente el token de acceso antes de devolver la respuesta. Tu controlador siempre ve un token utilizable (o `authFailedAt` establecido).
* `getConnection(id)` es el equivalente de una sola fila.
</Accordion>
<Accordion title="Visibilidad por usuario vs. compartida con el espacio de trabajo" description="Cómo los usuarios eligen entre credenciales privadas y compartidas">
Cuando un usuario hace clic en "Agregar conexión", se le solicita que elija una visibilidad:
* **Solo para mí** — la credencial es privada para el usuario que se conecta. Cualquier función de lógica llamada en su nombre (activador de ruta HTTP con `isAuthRequired: true`) la ve; los activadores de cron y los eventos de base de datos no.
* **Compartida en el espacio de trabajo** — cualquier miembro del espacio de trabajo puede usar la credencial. Los activadores de cron y de base de datos también la ven, ya que no tienen usuario de la solicitud.
Usa la adecuada para cada controlador:
```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');
```
Se permiten múltiples conexiones por (usuario, proveedor), por lo que el mismo usuario puede tener "Linear personal" y "Linear de trabajo" a la vez.
</Accordion>
<Accordion title="Configuración única del proveedor" description="Registra tu aplicación OAuth con el servicio de terceros">
Para cada proveedor de conexión, el administrador del servidor debe registrar primero una aplicación OAuth en el servicio de terceros.
1. Ve a la configuración de desarrollador del proveedor (p. ej., https://linear.app/settings/api/applications/new).
2. Configura el **URI de redirección** en `\<SERVER_URL>/auth/apps/callback`.
3. Copia el **Client ID** y el **Client Secret** generados.
4. Abre la aplicación instalada en Twenty como administrador del servidor → establece los valores en los `serverVariables` correspondientes.
5. Luego, los miembros del espacio de trabajo pueden agregar conexiones desde la sección **Conexiones** por aplicación.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: Almacén de clave-valor
description: Conserva resultados intermedios, almacena en caché datos y comparte estado entre ejecuciones de funciones lógicas con el almacén de clave-valor integrado de la aplicación.
icon: database
---
Las funciones de lógica se ejecutan aisladas en procesos de Node.js de corta duración; una vez que una ejecución finaliza, nada de lo que se mantuvo en memoria sobrevive. Cuando necesitas **recordar algo entre ejecuciones** (almacenar en caché una respuesta de API costosa, guardar un cursor para sincronizaciones incrementales, aplicar *debounce* al trabajo o traspasar estado de una función a otra), persiste esos datos en el almacén de clave-valor integrado.
Cada aplicación obtiene su propio espacio de nombres aislado: las entradas se asocian con la aplicación autenticada, por lo que tus claves nunca pueden entrar en conflicto con — ni ser leídas por — otra aplicación.
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## Obtener, establecer, eliminar
Importa `kv` desde `twenty-sdk/logic-function`. Los valores pueden ser cualquier carga útil serializable 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');
```
## Ámbitos
Cada entrada tiene un ámbito (*scope*), que se pasa como una opción en cada llamada. El valor predeterminado es `WORKSPACE`.
* **`WORKSPACE`** (predeterminado): la entrada es privada para la instalación actual de tu aplicación en el espacio de trabajo. Cada espacio de trabajo que instala la aplicación obtiene su propio conjunto independiente de claves. Esto es lo que quieres para cachés, cursores y estado por espacio de trabajo.
* **`SERVER`**: la entrada se comparte entre **todas las instalaciones** de tu aplicación en el servidor. Las entradas de servidor se comportan como reclamaciones: el valor almacenado es siempre el workspaceId que reclamó la clave (omite `value` en `set` para reclamar la clave para el espacio de trabajo actual), y solo ese espacio de trabajo puede sobrescribirla o eliminarla. Cualquier instalación puede leer la entrada.
Las reclamaciones de servidor existen para el enrutamiento entre espacios de trabajo. Un [server-route resolver](/l/es/developers/extend/apps/logic/logic-functions#server-route-trigger) se ejecuta en el espacio de trabajo propietario del registro de la aplicación, pero un webhook entrante normalmente solo lleva un id de cuenta externa, no un Twenty workspaceId. Haz que cada espacio de trabajo reclame su id externo en el momento de la conexión y luego resuélvelo en la ruta:
```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',
});
```
Como una clave de servidor solo puede ser reclamada para el propio espacio de trabajo de quien la llama y nunca puede ser sobrescrita por otro, un espacio de trabajo no puede secuestrar un mapeo que pertenezca a otra persona. `kv.set` produce una excepción cuando la clave ya ha sido reclamada por otro espacio de trabajo.
## Úsalo: almacena en caché una llamada costosa
Un uso típico es almacenar en caché una respuesta lenta o limitada por *rate limiting* de un tercero para que ejecuciones repetidas la reutilicen en lugar de pagar el coste cada vez.
```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,
});
```
## Patrones y consejos
* **Espacios de nombres.** Añade un prefijo a las claves para mantener separadas las distintas responsabilidades: `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
* **Caducidad (TTL).** El almacén no tiene caducidad integrada. Almacena una marca de tiempo dentro del valor (como en el ejemplo de caché) y revísala al leer, o borra las claves obsoletas desde una [función activada por cron](/l/es/developers/extend/apps/logic/logic-functions).
* **Qué almacenar.** Cualquier valor serializable en JSON: números, cadenas, arreglos, objetos. Mantén las entradas pequeñas; esto es para coordinación y almacenamiento en caché, no para *blobs* grandes o archivos. Para archivos, utiliza un campo `FILES` y [`uploadFile`](/l/es/developers/extend/apps/logic/logic-functions#uploading-files).
* **Visibilidad.** Las entradas residen en la base de datos de la instancia, no como registros del espacio de trabajo: nunca aparecen en la interfaz de usuario del espacio de trabajo, no forman parte del modelo de datos de tu aplicación y no necesitan permisos de rol ni de objeto.
## Alternativa: un objeto de almacenamiento consultable
El almacén integrado es deliberadamente opaco: las entradas no son registros, por lo que no puedes explorarlas en la interfaz de usuario, relacionarlas con otros objetos ni filtrarlas con consultas de registros. Cuando necesites algo de eso — por ejemplo, un registro de sincronización visible o estado por registro — define en su lugar un pequeño **objeto técnico** con un campo `key` único y un campo `value` de tipo `RAW_JSON`, y consúltalo mediante el [cliente de API tipado](/l/es/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk). Consulta [Objects](/l/es/developers/extend/apps/data/objects) para la referencia de `defineObject` y [Data → Unique indexes](/l/es/developers/extend/apps/data/overview#unique-indexes) para aplicar la unicidad de la clave.
* **Limitando el ámbito a un registro.** Añade una [relación](/l/es/developers/extend/apps/data/relations) desde el objeto de almacenamiento al objeto de destino en lugar de codificar el id en la clave.
* **Visibilidad y permisos.** Las filas residen en la base de datos del espacio de trabajo como cualquier otro registro, por lo que se pueden consultar a través de la API y respetan el [rol](/l/es/developers/extend/apps/config/roles) de tu aplicación. Para mantener el almacén fuera de la interfaz principal, déjalo fuera de tu [menú de navegación](/l/es/developers/extend/apps/layout/navigation-menu-items).
<Note>
A diferencia del almacén integrado, un objeto personalizado siempre está limitado a un solo espacio de trabajo: no puede compartir entradas entre instalaciones como lo hacen las claves `SERVER`.
</Note>

View File

@@ -0,0 +1,909 @@
---
title: Funciones de lógica
description: Defina funciones de TypeScript del lado del servidor con activadores HTTP, de cron y de eventos de base de datos.
icon: bolt
---
Las funciones lógicas son funciones de TypeScript del lado del servidor que se ejecutan en la plataforma Twenty. Pueden activarse mediante solicitudes HTTP, programaciones de cron o eventos de base de datos — y también pueden exponerse como herramientas para agentes de IA.
<AccordionGroup>
<Accordion title="defineLogicFunction" description="Define funciones de lógica y sus desencadenadores">
Cada archivo de función usa `defineLogicFunction()` para exportar una configuración con un controlador y desencadenadores opcionales.
```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 *',
},*/
});
```
Tipos de desencadenadores disponibles:
* **httpRoute**: Expone tu función en una ruta y método HTTP. En el código de la aplicación, añade el prefijo `/s/` a la ruta cuando uses `RestApiClient`; la URL desplegada utiliza la base inyectada `TWENTY_FUNCTIONS_URL` (o `\<server-url>/s` cuando no está configurada).
<Note>
Para invocar una función de lógica activada por una ruta desde un componente de frontend (headless), consulta [Llamar a una función de lógica](/l/es/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Ejecuta tu función en un horario usando una expresión CRON.
* **databaseEvent**: Se ejecuta en eventos del ciclo de vida de objetos del espacio de trabajo. Cuando la operación del evento es `updated`, se pueden especificar campos específicos que se deben escuchar en la matriz `updatedFields`. Si se deja sin definir o vacío, cualquier actualización activará la función.
> p. ej. `person.updated`, `*.created`, `company.*`
* **serverRoute**: expone una única ruta HTTP con ámbito de registro. Una función de **resolver** (declarada con `serverRouteTriggerSettings`) se ejecuta en el espacio de trabajo propietario y devuelve una `Response` síncrona o el espacio de trabajo de destino Y la función de lógica de destino para poner en cola; en la ruta de encolado, la plataforma confirma con `202` y ejecuta ese **destino** en la cola de workers. Consulta [Disparador de ruta de servidor](#server-route-trigger).
<Note>
También puedes ejecutar manualmente una función usando 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
```
Puedes ver los registros con:
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
#### Carga útil del disparador de ruta
Cuando un desencadenador de ruta invoca tu función de lógica, esta recibe un objeto `RoutePayload` que sigue el
[formato AWS HTTP API v2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Importa el tipo `RoutePayload` desde `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' };
};
```
El tipo `RoutePayload` tiene la siguiente estructura:
| Propiedad | Tipo | Descripción | Ejemplo |
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Encabezados HTTP (solo aquellos listados en `forwardedRequestHeaders`) | consulta la sección de abajo |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parámetros de consulta (valores múltiples unidos con comas) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parámetros de ruta extraídos del patrón de la ruta | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Cuerpo de la solicitud analizado (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Cuerpo de la solicitud UTF-8 original, antes del análisis de JSON. Útil para verificar firmas de webhooks de estilo HMAC (p. ej., `X-Hub-Signature-256` de GitHub, Stripe). `undefined` cuando el entorno de ejecución no lo conservó. | |
| `isBase64Encoded` | `boolean` | Indica si el cuerpo está codificado en base64 | |
| `requestContext.http.method` | `string` | Método HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Ruta de la solicitud sin procesar | |
#### forwardedRequestHeaders
De forma predeterminada, los encabezados HTTP de las solicitudes entrantes **no** se pasan a tu función de lógica por razones de seguridad.
Para acceder a encabezados específicos, enuméralos explícitamente en el arreglo `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'],
},
});
```
En tu controlador, accede a los encabezados reenviados así:
```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>
Los nombres de los encabezados se normalizan a minúsculas. Accede a ellos usando claves en minúsculas (p. ej., `event.headers['content-type']`).
</Note>
#### Respuesta HTTP personalizada
De forma predeterminada, devolver un valor sencillo desde tu controlador lo envía de vuelta como una respuesta `200` (JSON para objetos, `text/plain` para cadenas). Para controlar el código de estado y los encabezados de la respuesta, devuelve un `Response` desde `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' },
});
};
```
Por razones de seguridad, los encabezados de la respuesta están restringidos a una lista de permitidos. Cualquier encabezado que no esté en la lista (por ejemplo, `Set-Cookie`, encabezados CORS como `Access-Control-Allow-Origin`, o encabezados personalizados `X-*`) se descarta silenciosamente antes de que se envíe la respuesta. Los encabezados de respuesta permitidos son:
* `content-type`
* `content-language`
* `content-disposition`
* `cache-control`
* `retry-after`
<Note>
El código de estado debe ser un código de estado HTTP válido (entre 100 y 599). Los nombres de los encabezados de respuesta se comparan sin distinguir mayúsculas de minúsculas.
</Note>
#### Respuestas de error de la plataforma
Más allá de las propias respuestas de tu controlador, la plataforma responde directamente a las llamadas de ruta en algunas situaciones: `404` cuando la ruta o la función no existe, `403` cuando la aplicación está detenida, `429` cuando se alcanza el límite de velocidad de ejecución y `422` cuando las `dependencies` de producción de la aplicación son demasiado grandes para instalarlas; consulta [límites de tamaño de dependencias](/l/es/developers/extend/apps/getting-started/project-structure#dependencies).
#### Disparador de ruta de servidor
`httpRouteTriggerSettings` expone una función bajo `/s/` y resuelve el espacio de trabajo a partir del host de la solicitud — lo cual funciona cuando cada espacio de trabajo tiene su propio dominio. Los proveedores de terceros, sin embargo, entregan los eventos de cada inquilino a **una** URL. Para ese caso, usa `serverRouteTriggerSettings`.
El disparador tiene dos partes:
1. Una función de lógica de **resolver** — declarada con `serverRouteTriggerSettings` — se ejecuta en tu **espacio de trabajo propietario** (el espacio de trabajo que es propietario del registro de la aplicación). Inspecciona la solicitud entrante y devuelve una de las siguientes opciones:
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — la plataforma pone en cola ese destino en el espacio de trabajo resuelto y confirma con `202 { queued: true }`, o
* un `Response` de `twenty-sdk/logic-function` — la plataforma replica esa respuesta HTTP de forma **sincrónica** y **no** pone en cola un destino (usa esto para desafíos de verificación como `url_verification` de Slack).
El resolver es el único punto de autorización: la URL solo lleva el identificador del resolver. **Este es el lugar preferido para verificar las firmas de las solicitudes**: el resolver se ejecuta antes de cualquier efecto secundario, tiene acceso al `rawBody` original y a los encabezados reenviados, y puede rechazar sin tocar nunca el destino.
2. Luego, una función de lógica de **destino** — una función de lógica normal por espacio de trabajo — se ejecuta en el espacio de trabajo resuelto con el payload devuelto por el resolver (o el payload original de la solicitud si el resolver no lo transformó). Su valor de retorno **no** es observado por el solicitante HTTP cuando el resolver eligió la ruta de encolado.
```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,
});
```
El endpoint es accesible en:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
El identificador es el `universalIdentifier` del resolver de tu manifiesto. Registra esa URL con el proveedor.
**Responder a un `GET` de verificación.** Algunos proveedores verifican un punto de conexión antes de entregar contenido en él, enviando un `GET` con un desafío a la misma URL a la que más tarde enviarán eventos mediante `POST`; la API de WhatsApp Cloud de Meta es uno de ellos. Una ruta de servidor responde solo a `POST`, a menos que indiques lo contrario, así que declara ambos métodos:
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
El desafío llega en `event.queryStringParameters`, y devolver una `Response` lo reenvía al proveedor en la misma solicitud. Un cuerpo de cadena se envía como `text/plain`, que es lo que esperan estos proveedores:
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods` reemplaza el valor predeterminado en lugar de añadirse a él, por lo que `['GET']` por sí solo hace que la ruta rechace `POST`. Solo se admiten `GET` y `POST`. Déjalo sin configurar a menos que el proveedor necesite el segundo verbo: una ruta que declara `GET` tendrá su resolver ejecutado por cualquier llamador no autenticado, incluidos los rastreadores y los desplegadores de enlaces que envían `GET` sin solicitarlo. Cualquier solicitud para la que la plataforma no tenga un método se responde con `405` sin que el resolver llegue a ejecutarse.
<Note>
**La aplicación debe reclamarse e instalarse en el espacio de trabajo propietario.** Dado que el resolver se ejecuta en el **espacio de trabajo propietario** (el espacio de trabajo que es propietario del registro de la aplicación), un desencadenador de ruta de servidor solo funciona una vez que la aplicación ha sido *reclamada*, es decir, tiene un espacio de trabajo propietario, **y** esa aplicación está **instalada en el espacio de trabajo propietario**. Hasta que ambas condiciones se cumplan, el resolver no tiene dónde ejecutarse, por lo que la ruta no puede despacharse. Por lo tanto, una aplicación que expone una función lógica `serverRouteTriggerSettings` no puede figurar en el marketplace hasta que haya sido reclamada e instalada en su espacio de trabajo propietario.
</Note>
**Contrato del resolver.** El tipo `LogicFunctionConfig` del SDK aplica esto en tiempo de compilación: tan pronto como configuras `serverRouteTriggerSettings`, tu handler se ve obligado a devolver un `Response`, o `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (o un `Promise` de cualquiera de los dos). En la ruta de envío, el `workspaceId` debe ser un espacio de trabajo donde la función de destino esté instalada; de lo contrario, la solicitud se rechaza con `404`. Un resultado que no coincide con ninguna de las dos formas —incluido uno cuyos identificadores no sean UUID— se rechaza con `502`.
| Campo | Tipo | Notas |
| ---------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | UUID del espacio de trabajo donde se ejecutará el destino. |
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` de la función de lógica que se va a invocar en ese espacio de trabajo. |
| `payload` | `object` (opcional) | Si se establece, reemplaza el cuerpo de la solicitud enviado al destino. |
<Warning>
**La verificación de la firma es tu responsabilidad: verifica en el resolver.** La plataforma no verifica las firmas de las solicitudes. El resolver es el lugar recomendado para hacerlo: se ejecuta primero, con acceso a `event.rawBody` y a los encabezados que incluiste en `forwardedRequestHeaders`, y un error lanzado (o cualquier `workspaceId` que no coincida) detiene el despacho antes de que se invoque el destino. Si en cambio trasladas la verificación al destino, el destino debe tener cuidado de no perder `rawBody` y los encabezados; es decir, el resolver no debe devolver un `payload`. Verifica siempre **antes** de cualquier efecto secundario y usa una comparación en tiempo constante.
</Warning>
Para las firmas de solicitudes, la mayoría de los proveedores firman con HMAC-SHA256; las partes que difieren son el nombre del encabezado, la codificación del digest y la cadena firmada del payload. Algunos ejemplos:
| Proveedor | Encabezados a reenviar | Cadena firmada | Digest |
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | --------------------------------------------------------------- |
| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (el secreto está en base64 después de eliminar `whsec_`) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex (con el prefijo `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (con el prefijo `v0=`) |
El ejemplo de resolver anterior ya muestra el flujo HMAC-SHA256 de GitHub; adapta el nombre del encabezado, la codificación del digest y la cadena firmada del payload según el proveedor con el que te estés integrando.
<Note>
Cuando el resolver devuelve un objeto de envío, la ruta responde `202 { queued: true }` y el destino se ejecuta en la cola de workers: el solicitante nunca observa la latencia, el resultado ni los fallos del destino (estos se registran en los registros de ejecución). Esto evita que los reintentos del remitente amplifiquen las ralentizaciones del procesamiento, que es lo que se desea para la ingesta de webhooks.
Cuando el solicitante deba leer el cuerpo de la respuesta en la misma solicitud (desafíos de verificación, acuses de recibo interactivos), en su lugar devuelve un `Response` desde el **resolver**. La plataforma lo replica de forma síncrona y omite la cola; sus cabeceras pasan por la misma lista de permitidos que las respuestas de rutas HTTP. Mantén el resolver rápido: algunos proveedores (p. ej. Slack) agotan el tiempo de espera en pocos segundos. Como el resolver es accesible como un endpoint público, protégelo con limitación de tasa en el edge.
</Note>
#### Payload del disparador de evento de base de datos
Cuando un disparador de evento de base de datos invoca tu función de lógica, esta recibe un `DatabaseEventPayload` por cada registro modificado. El payload combina metadatos sobre el espacio de trabajo y el objeto de origen con el evento a nivel de registro.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
La carga útil incluye:
| Propiedad | Descripción |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `name` | Nombre del evento, como `person.updated`. |
| `workspaceId` | Espacio de trabajo donde ocurrió el evento. |
| `objectMetadata` | Metadatos del objeto que cambió. |
| `recordId` | Id del registro que cambió. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Campos del actor cuando el evento fue causado por un usuario del espacio de trabajo. |
| `propiedades` | Datos del registro para el evento, con `before`, `after`, `diff` y `updatedFields` según la operación. |
| Evento | Datos del registro |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
Para eliminaciones lógicas (soft deletes), `.deleted` sigue la estructura de estilo de actualización porque el campo `deletedAt` del registro cambia.
Para eliminaciones permanentes, usa `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filtra qué eventos de actualización activan la función.
`event.properties.updatedFields` te indica qué campos realmente cambiaron en el evento actual.
</Note>
Ejemplo de evento de creación:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Ejemplo de evento de actualización:
```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,
};
};
```
Ejecutar solo en actualizaciones de correo electrónico:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Ejemplo de evento de eliminación:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Contexto de ejecución
Cada controlador recibe un segundo argumento que describe la propia ejecución, independientemente de lo que la haya desencadenado. Mientras que el primer argumento cambia de forma según el desencadenante, este no:
```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 };
};
```
| Campo | Tipo | Descripción |
| ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | El espacio de trabajo al que pertenece la ejecución. |
| `userWorkspaceId` | `string \| null` | La persona que desencadenó la ejecución, como miembro de este espacio de trabajo. |
| `workspaceMemberId` | `string \| null` | La misma persona como un registro de miembro del espacio de trabajo, que es a lo que vinculas los registros. |
| `retryCount` | `number` | `0` en el primer intento. Consulta [Trabajos en segundo plano](/l/es/developers/extend/apps/logic/background-jobs). |
| `maxRetries` | `number` | Límite superior de los reintentos solicitados por la aplicación. |
`userWorkspaceId` y `workspaceMemberId` son `null` cuando nadie desencadenó la ejecución: las programaciones cron, los hooks de instalación y los webhooks no autenticados no tienen a ninguna persona detrás. También son `null` cuando la persona no tiene un registro de miembro del espacio de trabajo, o cuando el suyo se ha eliminado.
<Note>
**El contexto te indica quién desencadenó la ejecución.** Lo que la ejecución puede *hacer* es una declaración independiente, a continuación.
</Note>
#### Qué acceso utiliza una llamada
Cada cliente — `CoreApiClient`, `MetadataApiClient` y `RestApiClient` — actúa como la persona que desencadenó la ejecución: su rol se intersecta con el de tu aplicación, por lo que la llamada nunca puede hacer más de lo que cualquiera de los dos puede hacer. Ese es el comportamiento predeterminado y significa que una persona nunca puede usar tu aplicación para superar sus propios permisos.
Cuando nadie desencadenó la ejecución, no hay ninguna persona como la que actuar, por lo que el mismo cliente recurre al acceso propio de tu aplicación: las programaciones cron, los hooks de instalación y los webhooks no autenticados no requieren ninguna gestión especial.
Algunas llamadas necesitan legítimamente el acceso propio de la aplicación incluso cuando hay una persona detrás de la ejecución — leer los registros de configuración de tu aplicación o realizar trabajo que esa persona no podría hacer por sí misma. Crea un segundo cliente para esos casos:
```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: { ... } });
};
```
Crea ambos una vez, en el ámbito del módulo, y cada sitio de llamada indica entonces qué acceso utiliza según el cliente al que llama. `RestApiClient` acepta la misma opción:
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**Una ejecución que nadie desencadenó actúa como tu aplicación.** Las programaciones cron, los hooks de instalación y los webhooks no autenticados no tienen a ninguna persona detrás, por lo que el cliente predeterminado recurre al acceso propio de tu aplicación y sigue funcionando. `runAs: 'application'` solo se necesita cuando quieres ese acceso en una ejecución que una persona *sí* desencadenó.
Comprueba `context.workspaceMemberId` cuando una función se comporta de forma diferente según haya alguien detrás de ella, por ejemplo, para atribuir un registro.
</Note>
Los सहायantes del SDK que acceden a los recursos propios de tu aplicación siempre usan su acceso e ignoran `runAs`: el [almacén de clave-valor](/l/es/developers/extend/apps/logic/key-value-store), las [conexiones](/l/es/developers/extend/apps/logic/connections), `runAgent`, `getPublicAssetUrl` y el cobro de créditos.
#### Exponer una función como herramienta de IA o acción de flujo de trabajo
Las funciones lógicas pueden exponerse en dos ámbitos, cada uno con su propio disparador:
* **`toolTriggerSettings`** — hace que la función sea descubrible por las funciones de IA de Twenty (chat, MCP, llamadas a funciones). Usa el JSON Schema estándar, el formato que los LLM entienden de forma nativa.
* **`workflowActionTriggerSettings`** — hace que la función aparezca como un paso en el constructor visual de flujos de trabajo. Usa el `InputSchema` completo de Twenty para que el constructor pueda renderizar editores de campos adecuados, selectores de variables y etiquetas.
Una función puede optar por una, por la otra o por ambas. Se ubican junto a `cronTriggerSettings`, `databaseEventTriggerSettings` y `httpRouteTriggerSettings` — mismo patrón, misma estructura.
<Note>
**Relación con la acción Code del flujo de trabajo.** La acción integrada **Code** en el generador de flujos de trabajo es en sí misma una función lógica: Twenty crea una por cada paso de Code y expone su editor en línea. `workflowActionTriggerSettings` es la forma de convertir ese código puntual en línea en una acción **reutilizable**: defines la función una vez en tu aplicación y se vuelve seleccionable en cualquier flujo de trabajo, en lugar de copiarla y pegarla en cada paso de Code. Consulta la [acción Code](/l/es/user-guide/workflows/capabilities/workflow-actions#code) en la guía del usuario para ver la perspectiva del usuario 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: {},
});
```
Puntos clave:
* Una función puede mezclar superficies — declara tanto `toolTriggerSettings` como `workflowActionTriggerSettings` para exponerla en el chat Y en el constructor de flujos de trabajo.
* Ambos, `toolTriggerSettings.inputSchema` y `workflowActionTriggerSettings.inputSchema`, son opcionales. Cuando se omiten, el generador del manifiesto los infiere a partir del código fuente del controlador (JSON Schema para la herramienta de IA, `InputSchema` de Twenty para la acción de flujo de trabajo). Proporciona uno explícitamente cuando quieras un tipado más rico — por ejemplo, con campos compatibles con `FieldMetadataType` como `CURRENCY` o `RELATION` para el constructor de flujos de trabajo, o con campos `description` que el agente de IA pueda leer:
```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'],
},
},
});
```
Para declarar tus parámetros **una sola vez** y atender ambas superficies, define un único JSON Schema (`InputJsonSchema`) y conviértelo para la acción de flujo de trabajo con `jsonSchemaToInputSchema` de `twenty-sdk/logic-function`. `toolTriggerSettings.inputSchema` usa directamente el JSON Schema, mientras que `workflowActionTriggerSettings.inputSchema` espera el `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),
},
});
```
##### Ejemplo completo de una acción de flujo de trabajo
`workflowActionTriggerSettings` acepta cuatro campos:
| Campo | Propósito |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `label` | Nombre que se muestra para la acción en el selector de pasos del generador de flujos de trabajo. De manera predeterminada, usa el `name` de la función. |
| `icon` | Icono que se muestra junto a la acción (un nombre de `tabler-icons`, por ejemplo `IconBuilding`). |
| `inputSchema` | El completo `InputSchema` de Twenty: lo que el generador representa como campos configurables (con selectores de variables). Opcional; se infiere a partir del controlador cuando se omite. |
| `outputSchema` | Declara la forma que devuelve el controlador, para que **los pasos posteriores puedan asignar campos a su salida**. Opcional; sin él, la salida se expone como un único valor opaco. |
Uniéndolo todo: una función expuesta como una acción de flujo de trabajo, con una salida declarada para que los pasos posteriores puedan hacer referencia a `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' },
},
},
],
},
});
```
Una vez que la aplicación está instalada, **Enrich Company** aparece en el selector de acciones del generador de flujos de trabajo. El generador representa `companyName` y `domain` como campos de entrada (cada uno puede extraer valores de pasos anteriores), y los pasos posteriores pueden hacer referencia a las salidas `taskId` y `enriched` de ese paso.
<Note>
**Escribe una buena `description`.** Los agentes de IA dependen del campo `description` de la función para decidir cuándo usar la herramienta. Sé específico acerca de lo que hace la herramienta y cuándo debe invocarse.
</Note>
</Accordion>
</AccordionGroup>
<Note>
**Utilidades en tiempo de ejecución.** `twenty-sdk/utils` vuelve a exportar pequeñas utilidades en tiempo de ejecución para que los handlers nunca importen directamente desde `twenty-shared`. Por ejemplo, `isDefined(value)` devuelve `false` tanto para `null` como para `undefined` — utilízalo para acotar de forma segura las entradas opcionales de los handlers, que pueden llegar como `null` en tiempo de ejecución incluso cuando están tipadas como `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 de instalación** — los controladores de preinstalación, postinstalación y desinstalación — comparten este entorno de ejecución, pero se declaran con sus propias funciones `define` y no aceptan configuraciones de disparador. Consulta [Hooks de instalación](/l/es/developers/extend/apps/config/install-hooks) para `definePreInstallLogicFunction`, `definePostInstallLogicFunction` y `defineUninstallLogicFunction`.
</Note>
## Crear una actividad de línea de tiempo.
Use `createTimelineActivity()` para publicar un evento explícito del dominio desde una función lógica. Primero defina el evento como un [tipo de actividad de línea de tiempo](/l/es/developers/extend/apps/data/timeline-activity-types) y, a continuación, dirija el tipo y los objetos mediante sus identificadores universales estables:
```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 resuelve los identificadores universales en los ID de metadatos específicos de la instalación, valida que el tipo de actividad de línea de tiempo pertenezca a la aplicación que realiza la llamada y captura una instantánea de sus metadatos de presentación en la nueva actividad.
Las entradas obligatorias son `timelineActivityTypeUniversalIdentifier`, `targetObjectUniversalIdentifier` y `targetRecordId`. También puede proporcionar `happensAt`, `properties` y `workspaceMemberId`. `happensAt` controla la posición y la hora mostrada del evento en la línea de tiempo; de forma predeterminada, corresponde a la hora de creación.
Para asociar otro registro con el evento, proporcione `linkedRecordId` y `linkedObjectMetadataUniversalIdentifier` juntos. Además, puede proporcionar `linkedRecordCachedName` como alternativa histórica de visualización:
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
El rol de la función lógica necesita permiso de escritura en el objeto estándar `timelineActivity`. Mantenga los tipos de eventos explícitos sin vincular a una `action`; un tipo vinculado a una acción ya recibe eventos de auditoría automáticos y, de lo contrario, produciría filas duplicadas.
## Clientes de API tipados (twenty-client-sdk)
El paquete `twenty-client-sdk` proporciona dos clientes GraphQL tipados para interactuar con la API de Twenty desde tus funciones de lógica y componentes de frontend.
| Cliente | Importar | Endpoint | ¿Generado? |
| ------------------- | ---------------------------- | ---------------------------------------------------------------------- | --------------------------------------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — datos del espacio de trabajo (registros, objetos) | Sí, en tiempo de desarrollo/compilación |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — configuración del espacio de trabajo, cargas de archivos | No, viene preconstruido |
<AccordionGroup>
<Accordion title="CoreApiClient" description="Consultar y modificar datos del espacio de trabajo (registros, objetos)">
`CoreApiClient` es el cliente principal para consultar y mutar datos del espacio de trabajo. Se **genera a partir del esquema de tu espacio de trabajo** durante `yarn twenty dev` o `yarn twenty dev:build`, por lo que está completamente tipado para coincidir con tus objetos y campos.
```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,
},
});
```
El cliente usa una sintaxis de conjunto de selección: pasa `true` para incluir un campo, usa `__args` para los argumentos y anida objetos para las relaciones. Obtienes autocompletado completo y verificación de tipos basados en el esquema de tu espacio de trabajo.
<Note>
**CoreApiClient se genera en tiempo de desarrollo/compilación.** Si intentas usarlo sin ejecutar primero `yarn twenty dev` o `yarn twenty dev:build`, lanzará un error. La generación ocurre automáticamente: la CLI inspecciona el esquema GraphQL de tu espacio de trabajo y genera un cliente tipado usando `@genql/cli`.
</Note>
#### Uso de CoreSchema para anotaciones de tipos
`CoreSchema` proporciona tipos de TypeScript que coinciden con los objetos de tu espacio de trabajo; útil para tipar el estado de componentes o parámetros de funciones:
```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="Configuración del espacio de trabajo, aplicaciones y cargas de archivos">
`MetadataApiClient` viene preconstruido con el SDK (no se requiere generación). Consulta el endpoint `/metadata` para la configuración del espacio de trabajo, las aplicaciones y las cargas de archivos. Acepta la misma opción `runAs` que `CoreApiClient` — consulta [Qué acceso utiliza una llamada](#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 },
},
},
});
```
#### Subir archivos
El `MetadataApiClient` incluye un método `uploadFile` para adjuntar archivos a los campos de tipo archivo:
```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://...' }
```
| Parámetro | Tipo | Descripción |
| ---------------------------------- | -------- | ----------------------------------------------------------------------------- |
| `fileBuffer` | `Buffer` | El contenido sin procesar del archivo |
| `filename` | `string` | El nombre del archivo (se utiliza para el almacenamiento y la visualización) |
| `contentType` | `string` | Tipo MIME (de forma predeterminada es `application/octet-stream` si se omite) |
| `fieldMetadataUniversalIdentifier` | `string` | El `universalIdentifier` del campo de tipo de archivo de tu objeto |
Puntos clave:
* Utiliza el `universalIdentifier` del campo (no su ID específico del espacio de trabajo), por lo que tu código de carga funciona en cualquier espacio de trabajo donde esté instalada tu aplicación.
* La `url` devuelta es una URL firmada que puedes usar para acceder al archivo cargado.
</Accordion>
</AccordionGroup>
<Note>
Cuando tu código se ejecuta en Twenty (funciones de lógica o componentes de frontend), la plataforma inyecta credenciales como variables de entorno:
* `TWENTY_API_URL` — URL base de la API de Twenty
* `TWENTY_APP_ACCESS_TOKEN` — Clave de corta duración para el acceso predeterminado: el rol de una persona se intersecta con el de tu aplicación cuando hay alguien detrás de la ejecución; el rol propio de tu aplicación cuando no hay nadie. La persona es quien haya desencadenado la ejecución en una función de lógica, o quien esté viendo la página en un componente de frontend.
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — Clave de corta duración con alcance únicamente al rol propio de tu aplicación. Solo funciones de lógica, siempre se inyecta allí, y es lo que utiliza `runAs: 'application'`.
**No** necesitas pasar estas a los clientes — leen de `process.env` automáticamente, y [Qué acceso utiliza una llamada](#whose-access-a-call-uses) explica cómo elegir entre ellas. Los permisos propios de tu aplicación están determinados por el rol declarado con `defineApplicationRole()` (o referenciado mediante `defaultRoleUniversalIdentifier` en `application-config.ts`); una ejecución que actúa como una persona nunca puede superar ese rol ni el suyo.
</Note>

View File

@@ -0,0 +1,61 @@
---
title: Resumen
description: TypeScript del lado del servidor que se ejecuta dentro de Twenty, activado por rutas HTTP, programaciones cron, eventos de base de datos, herramientas de IA o acciones de flujos de trabajo.
icon: bolt
---
La **capa de lógica** de una app de Twenty es el código que *se ejecuta*: controladores de TypeScript del lado del servidor que reaccionan a solicitudes HTTP, programaciones cron y cambios en registros; habilidades y agentes de IA que viven dentro del espacio de trabajo; y conexiones OAuth que permiten que tus funciones actúen en nombre de un usuario en servicios de terceros.
```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) │
└────────────────────────────┘
```
## En esta sección
<CardGroup cols={2}>
<Card title="Funciones de lógica" icon="bolt" href="/l/es/developers/extend/apps/logic/logic-functions">
El bloque de construcción principal: tipos de disparadores, cargas útiles y el cliente de API tipado.
</Card>
<Card title="Habilidades y agentes" icon="robot" href="/l/es/developers/extend/apps/logic/skills-and-agents">
Instrucciones reutilizables para agentes de IA y asistentes con mensajes de sistema personalizados.
</Card>
<Card title="Conexiones" icon="plug" href="/l/es/developers/extend/apps/logic/connections">
Credenciales OAuth que tu app mantiene para servicios de terceros — Linear, GitHub, Slack y más.
</Card>
<Card title="Almacén de clave-valor" icon="database" href="/l/es/developers/extend/apps/logic/key-value-store">
Conserva el estado entre ejecuciones de funciones lógicas — cachés, cursores y declaraciones entre espacios de trabajo.
</Card>
<Card title="Trabajos en segundo plano" icon="layer-group" href="/l/es/developers/extend/apps/logic/background-jobs">
Encola la ejecución de una función lógica en los workers para evitar el límite de tiempo por ejecución.
</Card>
</CardGroup>
## Tipos de disparadores de un vistazo
Una función de lógica selecciona uno o más disparadores: cada entrada a continuación es un campo independiente en `defineLogicFunction()`:
| Disparador | Cuándo se ejecuta | Configuración |
| ------------------------------- | --------------------------------------------------------------- | ------------------------------- |
| **Ruta HTTP** | Una solicitud llega a tu endpoint `/s/\<path>` | `httpRouteTriggerSettings` |
| **Cron** | Coincide una expresión CRON | `cronTriggerSettings` |
| **Evento de base de datos** | Se crea, actualiza o elimina un registro del espacio de trabajo | `databaseEventTriggerSettings` |
| **Herramienta de IA** | Una funcionalidad de IA de Twenty decide llamar a tu función | `toolTriggerSettings` |
| **Acción del Flujo de Trabajo** | Un paso de flujo de trabajo invoca tu función | `workflowActionTriggerSettings` |
Las funciones se ejecutan en un entorno aislado en procesos independientes de Node.js y acceden al espacio de trabajo a través de un cliente de API tipado con un ámbito limitado al rol declarado en [`defineApplication()`](/l/es/developers/extend/apps/config/application).
<Note>
**Hooks de instalación**: el código que se ejecuta antes o después de la instalación comparte este entorno de ejecución pero usa sus propias funciones define y se encuentra en [Config → Install Hooks](/l/es/developers/extend/apps/config/install-hooks).
</Note>

View File

@@ -0,0 +1,187 @@
---
title: Habilidades y agentes
description: Define habilidades y agentes de IA para tu aplicación.
icon: robot
---
<Warning>
Las habilidades y los agentes están actualmente en pruebas alfa. La funcionalidad es operativa, pero sigue evolucionando.
</Warning>
Las aplicaciones pueden definir capacidades de IA que residen dentro del espacio de trabajo — instrucciones de habilidades reutilizables y agentes con prompts de sistema personalizados.
<AccordionGroup>
<Accordion title="defineSkill" description="Define habilidades para agentes de IA">
Las habilidades definen instrucciones y capacidades reutilizables que los agentes de IA pueden usar dentro de tu espacio de trabajo. Usa `defineSkill()` para definir habilidades con validación incorporada:
```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`,
});
```
Puntos clave:
* `name` es una cadena identificadora única de la habilidad (se recomienda kebab-case).
* `label` es el nombre para mostrar, legible para humanos, que aparece en la interfaz de usuario.
* `content` contiene las instrucciones de la habilidad — este es el texto que usa el agente de IA.
* `icon` (opcional) establece el icono mostrado en la interfaz de usuario.
* `description` (opcional) proporciona contexto adicional sobre el propósito de la habilidad.
</Accordion>
<Accordion title="defineAgent" description="Define agentes de IA con prompts personalizados">
Los agentes son asistentes de IA que viven dentro de tu espacio de trabajo. Usa `defineAgent()` para crear agentes con un prompt de sistema personalizado:
```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.',
});
```
Puntos clave:
* `name` es una cadena identificadora única del agente (se recomienda kebab-case).
* `label` es el nombre para mostrar que aparece en la interfaz de usuario.
* `prompt` es el mensaje del sistema que define el comportamiento del agente.
* `description` (opcional) proporciona contexto sobre lo que hace el agente.
* `icon` (opcional) establece el icono mostrado en la interfaz de usuario.
* `modelId` (opcional) reemplaza el modelo de IA predeterminado usado por el agente.
* `responseFormat` (opcional) controla la forma de la salida del agente. De forma predeterminada es `{ type: 'text' }` para texto de formato libre. Usa `{ type: 'json', schema }` para forzar una salida JSON estructurada.
De forma predeterminada, un agente devuelve texto de formato libre. Para obtener una salida estructurada, establece `responseFormat` en `{ type: 'json' }` y proporciona 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,
},
},
});
```
Notas sobre el esquema:
* El esquema es un objeto plano: el `type` de cada propiedad debe ser un tipo primitivo (`string`, `number` o `boolean`). Los objetos anidados y los arrays no son compatibles.
* `description` (opcional) en cada propiedad guía al modelo sobre qué debe poner allí.
* `required` (opcional) enumera las propiedades que el modelo siempre debe devolver.
* `additionalProperties: false` (opcional) prohíbe cualquier propiedad que no esté declarada en `properties`.
</Accordion>
<Accordion title="runAgent" description="Ejecutar un agente desde una función de lógica">
`runAgent()` permite que una función de lógica ejecute uno de los agentes de tu app (con sus skills y tools). Identifica el agente mediante el `universalIdentifier` que pasaste a `defineAgent()`. Pasa una cadena `prompt` o un historial de la conversación
en `messages`, pero no ambos:
```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.',
});
```
Para bots de múltiples turnos (Slack, Discord, Teams, …), pasa el historial del hilo como `messages` en lugar de un solo `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?' },
],
});
```
Puntos clave:
* Proporciona **exactamente uno** de `prompt` (cadena) o `messages` (de 1 a 100 entradas
de `{ role: 'user' | 'assistant', content: string }`).
* El agente se ejecuta **sincrónicamente** y puede leer/actualizar registros por sí mismo mediante sus propias tools; `runAgent()` se resuelve una vez que la ejecución finaliza.
* Una app solo puede ejecutar sus propios agentes.
* El [rol predeterminado](/l/es/developers/extend/apps/config/roles) de la app debe conceder el indicador de permiso `AI`; agrega `SystemPermissionFlag.AI` a sus `permissionFlagUniversalIdentifiers` (o establece `canAccessAllTools: true`).
Sin esto, `runAgent()` falla con un error de permisos.
* Establece un valor generoso de `timeoutSeconds` en la función de lógica: las ejecuciones de agentes pueden tardar varios segundos.
* `success` es `true` y `result` es no nulo cuando la ejecución finaliza; en caso de fallo `success` es `false`, `result` es `null`, y `error` contiene el motivo (por ejemplo, cuando el espacio de trabajo se queda sin créditos de AI en mitad de la ejecución).
```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>
**Evita los bucles:** si llamas a `runAgent()` desde un trigger de evento de base de datos `*.updated` y el agente actualiza el mismo registro, limita el alcance del trigger con `updatedFields` a un campo que el agente nunca escriba (por ejemplo, la URL de origen), o comprueba si algún campo de destino sigue vacío antes de llamar a `runAgent()`.
</Warning>
### Ejecución en nombre de un miembro del espacio de trabajo
Pasa `runAsWorkspaceMemberId` cuando la ejecución es activada por una persona — por ejemplo, un bot de chat que responde a un mensaje — para que el agente actúe como ese miembro en lugar de como la aplicación:
```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',
});
```
La ejecución entonces actúa con **el propio rol del miembro**: puede hacer todo lo que ese miembro puede hacer y nada más, los registros que crea se le atribuyen a él, y se aplican sus permisos a nivel de fila. El rol del agente no participa: es el valor predeterminado de la aplicación para ejecuciones sin nadie detrás de ellas. Omite el campo para ejecuciones autónomas (trabajos programados, disparadores de eventos de base de datos): esos conservan el rol propio del agente. La aplicación que actúa permanece asociada al contexto de la ejecución para fines de trazabilidad, sin restringir los permisos del miembro.
Tu aplicación es responsable de asociar a la persona que activó la ejecución con un miembro del espacio de trabajo. Para poder designar uno se requiere un token de acceso de la aplicación, y a quién puede
designar un token depende de si lleva asociado un usuario:
* Un token **sin usuario asociado** puede designar a cualquier miembro. Una función lógica se ejecuta
con uno, y lo mismo ocurre con los tokens generados mediante `client_credentials` o a partir de una clave de API.
* Un token emitido **en nombre de un usuario**, como el que recibe un componente de interfaz, solo puede
designar al miembro propio de ese usuario.
Cualquier otro caso — una sesión de usuario simple, una clave de API sin un token de la aplicación — no puede
designar a ningún miembro.
<Warning>
`runAgent()` produce un error cuando no se puede resolver el miembro del espacio de trabajo — un
miembro desconocido o eliminado, o uno sin rol. Nunca recurre al
rol propio del agente, ya que eso otorgaría más acceso del que solicitó quien llama.
</Warning>
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,131 @@
---
title: CLI
description: Comandos de `yarn twenty` para ejecutar funciones, transmitir registros en tiempo real, gestionar instalaciones de aplicaciones y cambiar entre remotos.
icon: terminal
---
La CLI de `yarn twenty` es tu interfaz para todo lo relacionado con la aplicación. Lista completa de comandos:
| Comando | Qué hace | Documentado en |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `dev` | Supervisar archivos fuente y sincronizar en vivo los cambios | [Inicio rápido](/l/es/developers/extend/apps/getting-started/quick-start) |
| `plan` | Previsualizar cambios de metadatos sin aplicarlos | [Sincronización y recuperación](/l/es/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan) |
| `apply` | Aplicar cambios de metadatos después de mostrar el plan | [Sincronización y recuperación](/l/es/developers/extend/apps/operations/sync-and-recovery) |
| `dev:build` | Compilar la aplicación y generar el cliente de la API (`--tarball` para empaquetar un `.tgz`) | [Publicación](/l/es/developers/extend/apps/operations/publishing) |
| `dev:typecheck` | Ejecutar la comprobación de tipos de TypeScript | [Pruebas](/l/es/developers/extend/apps/operations/testing) |
| `dev:add` | Generar una nueva entidad | [Generación de entidades](/l/es/developers/extend/apps/getting-started/scaffolding) |
| `dev:generate-client` | Regenerar el cliente de API tipado | esta página |
| `dev:function:exec` / `dev:function:logs` | Ejecutar funciones y transmitir sus registros | esta página |
| `dev:translations-extract` | Extraer cadenas traducibles en catálogos de `locales/` | [Traducciones](/l/es/developers/extend/apps/operations/translations) |
| `dev:catalog-sync` | Activar la sincronización del catálogo del marketplace | [Publicación](/l/es/developers/extend/apps/operations/publishing#how-marketplace-discovery-works) |
| `app:publish` / `app:install` / `app:uninstall` | Ciclo de vida de la publicación | [Publicación](/l/es/developers/extend/apps/operations/publishing) y esta página |
| `docker:*` | Administrar el contenedor del servidor local de Twenty | [Servidor local](/l/es/developers/extend/apps/getting-started/local-server) |
| `remote:*` | Administrar conexiones de servidor | esta página |
Todos los comandos aceptan `-r, --remote \<name>` para dirigirse a un remoto específico en lugar del predeterminado.
## Ejecutar funciones (`yarn twenty dev:function:exec`)
Ejecuta manualmente una función de lógica sin activarla mediante HTTP, cron o evento de base de datos:
```bash filename="Terminal"
# Execute by function name
yarn twenty dev:function:exec -n create-new-post-card
# Execute by universalIdentifier
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
# Pass a JSON payload
yarn twenty dev:function:exec -n create-new-post-card -p '{"name": "Hello"}'
# Execute the install and uninstall hooks
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
yarn twenty dev:function:exec --uninstall
```
## Ver registros de funciones (`yarn twenty dev:function:logs`)
Transmitir en tiempo real los registros de ejecución de las funciones de lógica de tu aplicación:
```bash filename="Terminal"
# Stream all function logs
yarn twenty dev:function:logs
# Filter by function name
yarn twenty dev:function:logs -n create-new-post-card
# Filter by universalIdentifier
yarn twenty dev:function:logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
<Note>
Esto es diferente de `yarn twenty docker:logs`, que muestra los registros del contenedor de Docker. `yarn twenty dev:function:logs` muestra los registros de ejecución de funciones de tu aplicación desde el servidor de Twenty.
</Note>
## Generando el cliente tipado (`yarn twenty dev:generate-client`)
Regenera el cliente de API tipado (`twenty-client-sdk`) a partir del esquema del remoto activo, sin compilar ni sincronizar una aplicación. Úsalo para obtener un cliente tipado en cualquier proyecto — como un servicio backend que vive en un repositorio separado — que se comunica con tu instancia de Twenty:
```bash filename="Terminal"
# In your project (no Twenty app definition required)
yarn add twenty-sdk twenty-client-sdk
# Connect to the Twenty instance to generate the client from
yarn twenty remote:add
# Generate the typed client into node_modules/twenty-client-sdk
yarn twenty dev:generate-client
```
Luego importa el cliente en tu código:
```typescript
import { CoreApiClient } from 'twenty-client-sdk/core';
```
Vuelve a ejecutar el comando cada vez que cambie tu modelo de datos para actualizar los tipos generados.
<Note>
El cliente se genera dentro de `node_modules`, por lo que no se incluye en tus commits de código. Ejecuta `yarn twenty dev:generate-client` después de cada instalación (por ejemplo, en un script de `postinstall` o en CI).
</Note>
## Desinstalar una aplicación (`yarn twenty app:uninstall`)
Elimina tu aplicación del espacio de trabajo activo:
```bash filename="Terminal"
yarn twenty app:uninstall
# Skip the confirmation prompt
yarn twenty app:uninstall --yes
```
## Gestión de remotos
Un **remoto** es un servidor de Twenty al que se conecta tu app. Durante la configuración, el generador crea uno automáticamente para ti. Puedes añadir más remotos o cambiar entre ellos en cualquier momento.
```bash filename="Terminal"
# 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 --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
# List all configured remotes
yarn twenty remote:list
# Set the active remote
yarn twenty remote:use <name>
# Check that the active remote's authentication is still valid
yarn twenty remote:status
# Remove a remote
yarn twenty remote:remove <name>
```
Tus credenciales se almacenan en `~/.twenty/config.json`.

View File

@@ -0,0 +1,32 @@
---
title: Resumen
description: "Compila, prueba y envía tu aplicación: comandos de CLI, pruebas de integración, CI y publicación en un servidor o en npm."
icon: rocket
---
La **capa de operaciones** es todo lo que haces *a* tu aplicación en lugar de *con* ella: invocar comandos de CLI, ejecutar pruebas de integración contra un servidor Twenty real, configurar CI y enviar versiones, ya sea como un tarball implementado en un único servidor o como un paquete de npm listado en el marketplace.
```text
develop ─▶ test ─▶ build ─▶ deploy / publish
─────── ──── ───── ─────────────────
yarn yarn yarn yarn twenty app:publish --private (tarball → one server)
twenty test twenty
dev dev:build yarn twenty app:publish (npm → marketplace)
```
## En esta sección
<CardGroup cols={2}>
<Card title="CLI" icon="terminal" href="/l/es/developers/extend/apps/operations/cli">
Referencia de `yarn twenty` — exec, logs, uninstall, remotes.
</Card>
<Card title="Sincronización y recuperación" icon="compass" href="/l/es/developers/extend/apps/operations/sync-and-recovery">
Qué comando usar y cuándo, cómo leer el diff de sincronización y una guía escalonada de recuperación.
</Card>
<Card title="Pruebas" icon="flask" href="/l/es/developers/extend/apps/operations/testing">
Configuración de Vitest, pruebas de integración, comprobación de tipos, flujo de trabajo de CI.
</Card>
<Card title="Publicación" icon="upload" href="/l/es/developers/extend/apps/operations/publishing">
Compilar, desplegar un tarball, publicar en npm, instalar.
</Card>
</CardGroup>

View File

@@ -0,0 +1,293 @@
---
title: Publicación
icon: upload
description: Distribuye tu aplicación de Twenty en el marketplace o despliégala internamente.
---
## Resumen
Una vez que tu aplicación esté [compilada y probada localmente](/l/es/developers/extend/apps/getting-started/concepts), tienes dos vías para distribuirla:
* **Desplegar un paquete tar** — sube tu aplicación directamente a un servidor Twenty específico para uso interno o privado.
* **Publicar en npm** — incluye tu aplicación en el marketplace de Twenty para que cualquier espacio de trabajo la descubra e instale.
Ambas rutas comienzan en el mismo paso de **build**.
## Compilar tu aplicación
Ejecuta el comando `build` para compilar tu aplicación y generar un `manifest.json` listo para distribución:
```bash filename="Terminal"
yarn twenty dev:build
```
Esto compila el código fuente de TypeScript, transpila las funciones de lógica y los componentes de frontend, y escribe todo en `.twenty/output/`. Agrega `--tarball` para generar también un paquete `.tgz` para la distribución manual o para el comando `publish`.
## Despliegue en un servidor (tarball)
Para aplicaciones que no quieres que estén disponibles públicamente — herramientas propietarias, integraciones solo para empresas o compilaciones experimentales — puedes desplegar un tarball directamente en un servidor de Twenty.
### Prerrequisitos
Antes de desplegar, necesitas un remoto configurado que apunte al servidor de destino. Los remotos almacenan la URL del servidor y las credenciales de autenticación localmente en `~/.twenty/config.json`.
Agrega un remoto:
```bash filename="Terminal"
yarn twenty remote:add --url https://your-twenty-server.com --as production
```
### Despliegue
Compila y sube tu aplicación al servidor en un solo paso:
```bash filename="Terminal"
yarn twenty app:publish --private
# To deploy to a specific remote:
# yarn twenty app:publish --private --remote production
```
### Compartir una aplicación desplegada
Las aplicaciones en tarball no se listan en el marketplace público, por lo que otros espacios de trabajo en el mismo servidor no las descubrirán navegando. Para compartir una aplicación desplegada:
1. Ve a **Configuración > Aplicaciones > Registros** y abre tu aplicación
2. En la pestaña **Distribución**, haz clic en **Copiar enlace para compartir**
3. Comparte este enlace con usuarios de otros espacios de trabajo — los llevará directamente a la página de instalación de la aplicación
El enlace para compartir usa la URL base del servidor (sin ningún subdominio de espacio de trabajo), por lo que funciona para cualquier espacio de trabajo en el servidor.
### Gestión de versiones
Al actualizar una aplicación tarball ya desplegada, el servidor requiere que la `version` en `package.json` sea **estrictamente mayor** (según el orden de [semver](https://semver.org)) que la versión actualmente desplegada. Volver a desplegar la misma versión, o subir una inferior, se rechaza antes de que se almacene el tarball — verás un error `VERSION_ALREADY_EXISTS` en la CLI.
Para publicar una actualización:
1. Incrementa el campo `version` en tu `package.json` (p. ej., `1.2.3` → `1.2.4`, `1.3.0` o `2.0.0`)
2. Ejecuta `yarn twenty app:publish --private` (o `yarn twenty app:publish --private --remote production`)
3. Los espacios de trabajo que tienen la aplicación instalada y la actualización automática habilitada (en la pestaña Configuración de la aplicación) se actualizan automáticamente en segundo plano; los demás verán la actualización disponible en su configuración
<Note>
Las etiquetas de prelanzamiento funcionan como se espera: incrementar `1.0.0-rc.1` → `1.0.0-rc.2` está permitido, y una versión final como `1.0.0` se reconoce correctamente como superior a `1.0.0-rc.5`. La versión en `package.json` debe ser en sí misma una cadena semver válida.
</Note>
{/* TODO: add screenshot of the Upgrade button */}
### Compatibilidad de la versión del servidor
Si tu app usa una función introducida en una versión específica del servidor Twenty (por ejemplo, proveedores de OAuth agregados en v2.3.0), debes declarar la versión mínima del servidor que tu app requiere usando el campo `engines.twenty` en `package.json`:
```json filename="package.json"
{
"name": "twenty-my-app",
"version": "1.0.0",
"engines": {
"node": "^24.5.0",
"twenty": ">=2.3.0"
}
}
```
El valor es un [rango semver](https://github.com/npm/node-semver#ranges) estándar. Patrones comunes:
| Rango | Significado |
| ---------------------------------- | -------------------------------------------------------------------- |
| `>=2.3.0` | Cualquier servidor desde 2.3.0 en adelante |
| `>=2.3.0 \<3.0.0` | 2.3.0 o posterior, pero por debajo de la siguiente versión principal |
| `^2.3.0` | Igual que `>=2.3.0 \<3.0.0` |
**Qué sucede durante la implementación e instalación:**
* Si `engines.twenty` está configurado y la versión del servidor de destino no cumple el rango, la implementación (carga del tarball) o la instalación se rechaza con un error `SERVER_VERSION_INCOMPATIBLE` y un mensaje que indica tanto el rango requerido como la versión real del servidor.
* Si `engines.twenty` **no está configurado**, la app se acepta en cualquier versión del servidor (retrocompatible con las apps existentes).
* Si el servidor no tiene `APP_VERSION` configurado, se omite la comprobación.
<Note>
El servidor es la autoridad en la comprobación — valida `engines.twenty` tanto en la carga del tarball como en la instalación en el espacio de trabajo. Si implementas un tarball fuera de banda o instalas desde el marketplace, el servidor sigue garantizando la compatibilidad.
</Note>
## CI/CD automatizado (flujos de trabajo preconfigurados)
Las aplicaciones generadas con `create-twenty-app` incluyen de forma predeterminada tres flujos de trabajo de GitHub Actions, en `.github/workflows/`. La CI se ejecuta sin configuración, la CD requiere un único secreto y la publicación en npm requiere una configuración por única vez de Trusted Publisher de npm.
### CI — `ci.yml`
Ejecuta pruebas de integración en cada push a `main` y en cada pull request.
**Qué hace:**
1. Obtiene el código fuente de tu aplicación.
2. Inicia una instancia de prueba aislada de Twenty usando la acción compuesta `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (el equivalente en CI de `yarn twenty docker:start --test`).
3. Habilita Corepack, configura Node.js desde tu `.nvmrc` e instala las dependencias con `yarn install --immutable`.
4. Ejecuta `yarn test`, pasando `TWENTY_API_URL` y `TWENTY_API_KEY` de la instancia iniciada para que tus pruebas puedan comunicarse con un servidor real.
**Ajustes de configuración:**
* `TWENTY_VERSION` (variable de entorno; por defecto `latest`) — fija la versión del servidor de Twenty usada en CI editando esto en `ci.yml`.
* La concurrencia se agrupa por `github.ref` y cancela las ejecuciones en progreso cuando hay nuevos pushes.
No se requieren secretos — la instancia de prueba es efímera y existe solo durante la ejecución del trabajo.
### CD — `cd.yml`
Despliega tu aplicación en un servidor de Twenty configurado en cada push a `main` y, opcionalmente, desde un pull request cuando se aplica la etiqueta `deploy`.
**Qué hace:**
1. Obtiene el head del PR (para PR etiquetados) o el commit enviado.
2. Ejecuta `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — el equivalente en CI de `yarn twenty app:publish --private`.
3. Ejecuta `twentyhq/twenty/.github/actions/install-twenty-app@main` para que la versión recién desplegada se instale en el espacio de trabajo de destino.
**Configuración necesaria:**
| Configuración | Dónde | Propósito |
| ----------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` en `cd.yml` (por defecto `http://localhost:3000`) | El servidor de Twenty al que se va a desplegar. Cámbialo por la URL real de tu servidor antes del primer uso. |
| `TWENTY_DEPLOY_API_KEY` | Repositorio de GitHub **Settings → Secrets and variables → Actions** | Clave de API con permiso de despliegue en el servidor de destino. |
<Note>
La `TWENTY_DEPLOY_URL` predeterminada de `http://localhost:3000` es un marcador de posición — no alcanzará nada desde un runner alojado por GitHub. Actualízala a la URL pública de tu servidor (o usa un runner autohospedado con acceso a la red) antes de habilitar CD.
</Note>
**Activar un despliegue de vista previa desde un PR:**
Añade la etiqueta `deploy` a un pull request. La condición `if:` en `cd.yml` ejecutará el trabajo para ese PR usando el commit head del PR, lo que te permitirá validar un cambio en el servidor de destino antes de hacer merge.
### Publicar — `publish.yml`
Publica tu aplicación en npm con procedencia cuando haces push de una etiqueta de versión (por ejemplo, `v1.0.0`), o cuando ejecutas el flujo de trabajo manualmente desde la pestaña Actions.
**Qué hace:**
1. Hace checkout de tu aplicación, configura Node.js y actualiza npm (la publicación de confianza requiere npm 11.5.1 o posterior).
2. Ejecuta `yarn twenty app:publish`, que compila la aplicación y publica `.twenty/output` en npm. En CI agrega automáticamente `--provenance` y `--access public`, por lo que no se necesitan banderas en el flujo de trabajo.
**Configuración única:**
En npmjs.com abre tu paquete > **Settings → Trusted Publisher** y registra este repositorio con el flujo de trabajo `publish.yml` (consulta la [documentación de publicación de confianza de npm](https://docs.npmjs.com/trusted-publishers)). Publicar con provenance certifica qué repositorio de GitHub compiló el paquete, lo cual también es cómo reclamas la propiedad de tu aplicación en un marketplace de Twenty.
<Note>
npm solo acepta procedencia de repositorios de código fuente **públicos**. Si publicas desde un repositorio privado, npm rechaza el paquete de procedencia OIDC con un `E422 ... Visibilidad del repositorio de origen de GitHub Actions no compatible: "private"` error. Para publicar desde un repositorio privado, excluya la procedencia configurando `TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'` en el `env` del paso de publicación (se incluye una sugerencia comentada en el archivo `publish.yml` generado automáticamente):
```yaml filename=".github/workflows/publish.yml"
- name: Publish to npm
env:
TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'
run: yarn twenty app:publish
```
</Note>
### Fijar las acciones reutilizables
Los flujos de trabajo `ci.yml` y `cd.yml` hacen referencia a acciones reutilizables en `@main`, por lo que las actualizaciones de acciones en el repositorio `twentyhq/twenty` se aplican automáticamente. Si quieres compilaciones deterministas, reemplaza `@main` por un SHA de commit o una etiqueta de versión en cada línea `uses:`.
## Publicación en npm
Publicarla en npm hace que tu aplicación sea visible en el marketplace de Twenty. Cualquier espacio de trabajo de Twenty puede explorar, instalar y actualizar aplicaciones del marketplace directamente desde la interfaz de usuario.
### Requisitos
* Una cuenta de [npm](https://www.npmjs.com)
* La palabra clave `twenty-app` en la matriz `keywords` de tu `package.json` (agrégala manualmente — no se incluye de forma predeterminada en la plantilla `create-twenty-app`)
```json filename="package.json"
{
"name": "twenty-app-postcard-sender",
"version": "1.0.0",
"keywords": ["twenty-app"]
}
```
### Metadatos del Marketplace
La configuración de `defineApplication()` admite campos opcionales que controlan cómo aparece tu aplicación en el marketplace. Usa `logo` y `galleryImages` para hacer referencia a imágenes de la carpeta `public/`:
```ts src/application-config.ts
export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
description: 'A great app',
logo: 'public/logo.png',
galleryImages: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
});
```
Consulta el [acordeón de defineApplication](/l/es/developers/extend/apps/config/application#marketplace-metadata) en la página Building Apps para ver la lista completa de campos del marketplace (`author`, `category`, `aboutDescription`, `websiteUrl`, `termsUrl`, etc.).
#### Dimensiones recomendadas de las imágenes de la galería
El marketplace muestra `galleryImages` en un contenedor fijo de `8:5` (por ejemplo, `1600×1000 px`).
<Note>
Las imágenes de la galería de cualquier relación de aspecto se muestran completas y nunca se recortan, pero las que sean significativamente más altas o más estrechas que `8:5` mostrarán franjas vacías a los lados.
</Note>
#### Límite de tamaño de imagen
El `logo` y cada archivo de `galleryImages` no deben superar los **10 MB**. Los archivos más grandes se omiten cuando el marketplace vuelve a alojar tus recursos publicados, por lo que no se mostrarán.
### Publicar
```bash filename="Terminal"
yarn twenty app:publish
```
Para publicar con una dist-tag específica (p. ej., `beta` o `next`):
```bash filename="Terminal"
yarn twenty app:publish --tag beta
```
### Cómo funciona el descubrimiento en el marketplace
El servidor de Twenty sincroniza su catálogo del marketplace desde el registro de npm **cada hora**.
Puedes activar la sincronización de inmediato en lugar de esperar:
```bash filename="Terminal"
yarn twenty dev:catalog-sync
# To target a specific remote:
# yarn twenty dev:catalog-sync --remote production
```
Los metadatos que se muestran en el marketplace provienen de tu configuración de `defineApplication()`; consulta [Metadatos del marketplace](#marketplace-metadata) arriba.
<Note>
Si tu aplicación no define un `aboutDescription` en `defineApplication()`, el marketplace usará automáticamente el `README.md` de tu paquete en npm como el contenido de la página Acerca de. Esto significa que puedes mantener un único README tanto para npm como para el marketplace de Twenty. Si quieres una descripción diferente en el marketplace, establece explícitamente `aboutDescription`.
</Note>
### Publicación en CI
El flujo de trabajo `publish.yml` generado automáticamente descrito arriba publica en npm de forma automática en las etiquetas de versión, con provenance. Como `yarn twenty app:publish` añade `--provenance` y `--access public` por ti cuando se ejecuta en CI, el flujo de trabajo no necesita banderas de npm, solo la configuración única de trusted publisher.
Para otros sistemas de CI (GitLab CI, CircleCI, etc.), ejecuta `yarn install` y luego `yarn twenty app:publish`. La provenance se emite cuando el entorno puede generar un token OIDC y, en caso contrario, se omite automáticamente.
<Note>
La **provenance de npm** añade una insignia de confianza a tu ficha de npm, permitiendo que los usuarios verifiquen que el paquete se compiló a partir de un commit específico en una canalización de CI pública. También es lo que te permite reclamar la propiedad de tu aplicación en un marketplace de Twenty. Consulta la [documentación de npm sobre provenance](https://docs.npmjs.com/generating-provenance-statements) para más detalles.
</Note>
## Instalar aplicaciones
Una vez que una aplicación esté publicada (npm) o desplegada (tarball), los espacios de trabajo pueden instalarla a través de la interfaz de usuario.
Ve a la página **Configuración > Aplicaciones** en Twenty, donde se pueden explorar e instalar tanto las aplicaciones del marketplace como las desplegadas mediante tarball.
{/* TODO: add screenshot of the UI when the app is registered */}
También puedes instalar aplicaciones desde la línea de comandos:
```bash filename="Terminal"
yarn twenty app:install
```
<Note>
El servidor aplica el versionado semver al instalar, reflejando las reglas del despliegue:
* Instalar la misma versión que ya está instalada en tu espacio de trabajo se rechaza con un error `APP_ALREADY_INSTALLED`.
* Instalar una versión inferior a la que está instalada actualmente se rechaza con un error `CANNOT_DOWNGRADE_APPLICATION`.
Para instalar una versión más reciente, primero despliégala o publícala y luego vuelve a ejecutar `yarn twenty app:install`.
</Note>

View File

@@ -0,0 +1,126 @@
---
title: Sincronización y recuperación
description: Qué comando usar y cuándo, cómo leer la salida de sincronización y una escalera de recuperación para cuando los metadatos locales se desvían, antes de llegar a un restablecimiento completo.
icon: compass
---
El desarrollo de aplicaciones locales gira en torno a la **sincronización**: la CLI recompila tu manifiesto y el servidor aplica solo la diferencia entre este y los metadatos que ya se encuentran en tu espacio de trabajo. Esta página explica qué comando usar, cómo leer qué cambió una sincronización y qué hacer, en orden, cuando el estado local parece inconsistente.
## Qué comando usar y cuándo
<Note>
Para la iteración local del día a día casi siempre quieres `yarn twenty dev`. La implementación y la publicación son para enviar versiones, **no** para el ciclo local.
</Note>
| Quieres… | Comando | Notas |
| --------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Iterar localmente con sincronización en tiempo real | `yarn twenty dev` | Supervisa tus archivos y sincroniza en cada cambio. |
| Sincronizar una vez y salir (CI, scripts, hooks) | `yarn twenty apply` | Una compilación + sincronización, luego sale. Añade `--force` para omitir la confirmación de cambio destructivo. |
| Previsualizar cambios **sin aplicarlos** | `yarn twenty plan` | Calcula e imprime el diff; no escribe nada. |
| Eliminar la aplicación del espacio de trabajo | `yarn twenty app:uninstall` | Agrega `--yes` para omitir la confirmación. |
| Enviar un tarball a un servidor | `yarn twenty app:publish --private` | Requiere una versión de `package.json` **estrictamente superior**; consulta [Publicación](/l/es/developers/extend/apps/operations/publishing). |
| Publicar en el marketplace (npm) | `yarn twenty app:publish` | — |
| Instalar / actualizar una versión implementada | `yarn twenty app:install` | Instala la versión actualmente implementada. |
| Borrar el servidor local y empezar desde cero | `yarn twenty docker:reset` | Elimina **todos** los datos locales: último recurso. |
<Note>
`yarn twenty dev --once` y `yarn twenty dev --once --dry-run` siguen funcionando como alias obsoletos de `yarn twenty apply` y `yarn twenty plan`.
</Note>
### La sincronización local no necesita un aumento de versión
La regla de `version` estrictamente creciente (`VERSION_ALREADY_EXISTS` al implementar, `APP_ALREADY_INSTALLED` / `CANNOT_DOWNGRADE_APPLICATION` al instalar) se aplica a **`app:publish` / `app:install`**: la ruta de publicación. `yarn twenty dev` sincroniza tu manifiesto en su lugar y nunca requiere un cambio de versión, por lo que no necesitas tocar `package.json` para iterar. Si te encuentras aumentando la versión para probar un cambio local, estás usando la ruta de publicación cuando lo que quieres es el ciclo de desarrollo.
## Leer la salida de la sincronización
Cada sincronización imprime los cambios de metadatos que aplicó (o aplicaría, con `plan`), al estilo de Terraform: un bloque por entidad con sus atributos y luego una línea de resumen:
```text filename="Terminal"
# objectMetadata "rocket" will be created
+ icon = "IconRocket"
+ labelSingular = "Rocket"
+ ...
# fieldMetadata "launchedAt" will be updated
~ isNullable = false -> true
Plan: 2 to add, 1 to change, 1 to destroy.
✓ Synced My App (4 files)
```
Este es tu primer diagnóstico: te indica exactamente qué objetos, campos y diseños cambiaron, para que puedas confirmar que una sincronización hizo lo que esperabas antes de revisar la interfaz de usuario.
Los cambios destructivos (`to destroy`) se enumeran con lo que eliminan (p. ej., `objectMetadata "auditNote" — drops the table and all its rows`) y requieren confirmación interactiva, o `--force` en scripts.
Cuando una sincronización falla en una sola entidad, el error nombra la entidad implicada y su `universalIdentifier`, por ejemplo:
```text
Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020...4337) failed
```
Usa ese identificador para encontrar la entidad en tu manifiesto (y, si es necesario, en el espacio de trabajo) en lugar de adivinar cuál entra en conflicto.
## Previsualizar cambios (plan)
`yarn twenty plan` compila tu manifiesto, le pide al servidor el plan de migración y lo imprime, **sin aplicar nada**. Es la forma segura de responder "¿qué cambiaría esta sincronización?" antes de comprometerte a ella.
```bash filename="Terminal"
yarn twenty plan
```
```text filename="Terminal"
Building manifest...
Computing metadata plan (read-only, nothing will be applied)...
# fieldMetadata "crewCapacity" will be created
+ ...
Plan: 1 to add, 1 to change, 0 to destroy.
✓ Plan complete for My App — no changes were applied
```
Un plan:
* **No escribe nada**: sin migración de metadatos, sin actualización del registro de la aplicación, sin cambios de roles/pestañas predeterminados y sin generación del cliente de la API.
* Devuelve el **mismo diff** que aplicaría una sincronización real, para que puedas revisar por adelantado las entidades creadas/actualizadas/eliminadas.
* Es útil antes de un cambio arriesgado, al revisar un cambio generado por IA o en un script que deba fallar si está a punto de producirse un cambio inesperado.
<Note>
Un plan solo previsualiza cambios de **metadatos** y requiere que la aplicación se haya sincronizado al menos una vez (para que el espacio de trabajo la conozca). Si la ejecutas con una aplicación que nunca se sincronizó, el servidor indicará que la aplicación no está instalada; ejecuta `yarn twenty dev` una vez primero.
</Note>
## Escalera de recuperación
Cuando los metadatos locales parezcan incorrectos, ve escalando en este orden y detente en cuanto te hayas desbloqueado. Cada paso es más disruptivo que el anterior.
1. **Volver a sincronizar.** Ejecuta `yarn twenty apply` de nuevo. Las sincronizaciones son idempotentes: volver a ejecutar un manifiesto limpio es seguro y suele resolver un problema transitorio.
2. **Previsualizar el plan.** Ejecuta `yarn twenty plan` para ver exactamente qué pretende cambiar la siguiente sincronización, sin aplicarlo.
3. Lee el error identificado. Un conflicto suele señalar un identificador duplicado o reutilizado.
4. **Desinstalar y volver a instalar.** `yarn twenty app:uninstall`, luego vuelve a sincronizar (`yarn twenty dev`). Esto reconstruye los metadatos de la aplicación desde cero manteniendo intacto el resto de tu espacio de trabajo.
5. **Restablecimiento completo (último recurso).** `yarn twenty docker:reset`, luego vuelve a sembrar los datos y a sincronizar.
<Warning>
`yarn twenty docker:reset` elimina **todos** los datos de tu instancia local: todos los espacios de trabajo, registros y aplicaciones. Úsalo solo cuando los pasos anteriores hayan fallado.
</Warning>
<Note>
¿Te has encontrado con un error de metadatos? Por favor, [abre una incidencia](https://github.com/twentyhq/twenty/issues/new/choose) e incluye el mensaje de migración con error (con su tipo de metadatos y `universalIdentifier`), la salida de `Metadata changes` de la sincronización y los comandos que ejecutaste.
</Note>
## Evita sincronizaciones concurrentes en un mismo espacio de trabajo
La sincronización aplica migraciones de metadatos. Ejecutar varias operaciones de sincronización, implementación o instalación contra el **mismo espacio de trabajo al mismo tiempo** (por ejemplo, múltiples terminales o agentes de IA iterando en paralelo) puede entremezclar esas migraciones y dejar los metadatos en un estado parcialmente aplicado.
El servidor serializa las sincronizaciones por espacio de trabajo para evitar esto, pero aun así deberías canalizar las operaciones de metadatos sensibles a través de un proceso **único** en lugar de lanzarlas de forma concurrente. Si orquestas el desarrollo con varios agentes, enruta sus llamadas de sincronización/implementación/instalación a través de una sola cola de modo que solo una se ejecute a la vez.
## Distinguir los tipos de error
Cuando algo sale mal, el diff de metadatos y los errores con nombre te permiten situar el fallo:
* **Error de compilación del manifiesto**: la CLI falla antes de sincronizar (`MANIFEST_BUILD_FAILED`, `TYPECHECK_FAILED`); corrige el código fuente de tu aplicación.
* **Error de sincronización / migración**: la compilación tiene éxito, pero aplicar el diff falla, nombrando la entidad y el `universalIdentifier`; corrige los metadatos en conflicto.
* **Error de tamaño de dependencias** — la sincronización o la instalación fallan porque las `dependencies` de producción de la aplicación son demasiado grandes para instalarse como una capa de tiempo de ejecución (`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED`); mueve a `devDependencies` los paquetes que tus funciones de lógica no importan en tiempo de ejecución (bibliotecas de UI, herramientas de desarrollo).
* **Error de tiempo de ejecución del código de la aplicación**: la sincronización se completa correctamente, pero tus funciones lógicas o componentes se comportan de forma incorrecta en tiempo de ejecución; revisa los [registros de funciones](/l/es/developers/extend/apps/operations/cli).
* **Estado de instancia local**: nada de lo anterior aplica y el espacio de trabajo sigue viéndose mal; desciende por la escalera de recuperación.

View File

@@ -0,0 +1,264 @@
---
title: Pruebas
description: Configuración de Vitest, pruebas de integración contra un servidor real de Twenty, comprobación de tipos e integración continua (CI) con GitHub Actions.
icon: flask
---
El SDK proporciona APIs programáticas que te permiten compilar, desplegar, instalar y desinstalar tu aplicación desde código de pruebas. Combinado con [Vitest](https://vitest.dev/) y los clientes de API tipados, puedes escribir pruebas de integración que verifiquen que tu aplicación funciona de extremo a extremo contra un servidor real de Twenty.
## Uso de paquetes de npm
Puedes instalar y usar cualquier paquete de npm en tu aplicación. Tanto las funciones de lógica como los componentes de frontend se empaquetan con [esbuild](https://esbuild.github.io/), que incorpora sus dependencias en la salida — no se necesitan `node_modules` en tiempo de ejecución.
### Instalar un paquete
```bash filename="Terminal"
yarn add axios
```
Luego impórtalo en tu código:
```ts src/logic-functions/fetch-data.ts
import { defineLogicFunction } from 'twenty-sdk/define';
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,
});
```
Lo mismo funciona para los componentes de frontend:
```tsx src/front-components/chart.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
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,
});
```
### Cómo funciona el empaquetado
El paso de compilación usa esbuild para producir un solo archivo autónomo por función de lógica y por componente de frontend. Los paquetes importados se integran en el paquete, excepto los que se enumeran a continuación.
**Las funciones de lógica** se ejecutan en un entorno Node.js. Los módulos integrados de Node (`fs`, `path`, `crypto`, `http`, etc.) están disponibles y no necesitan instalarse.
**Los componentes de frontend** se ejecutan en un Web Worker. Los módulos integrados de Node **no** están disponibles — solo paquetes de npm que funcionen en un entorno de navegador. Ten en cuenta que la sandbox implementa un DOM *parcial*, por lo que un paquete puede compilarse correctamente y aun así fallar en tiempo de ejecución; consulta [Limitaciones actuales](/l/es/developers/extend/apps/layout/front-components#current-limitations).
Ambos entornos tienen `twenty-client-sdk/core` y `twenty-client-sdk/metadata` disponibles como módulos preproporcionados — estos no se incluyen en el bundle sino que se resuelven en tiempo de ejecución por el servidor.
Los componentes de frontend también pueden compartir dependencias mediante un [paquete de dependencias compartidas](/l/es/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components). Los paquetes listados en el campo `frontComponentSharedDependencies` del package.json de la aplicación se construyen una sola vez en un archivo separado y se cargan desde allí en tiempo de ejecución en lugar de integrarse en cada componente, por lo que un componente que importa solo paquetes compartidos prácticamente no lleva código de dependencias propio.
## Configuración
La aplicación generada ya incluye Vitest. Si lo configuras manualmente, instala las dependencias:
```bash filename="Terminal"
yarn add -D vitest vite-tsconfig-paths
```
Crea un `vitest.config.ts` en la raíz de tu aplicación:
```ts vitest.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '<the pre-seeded local dev key>';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});
```
Crea un archivo de configuración global que verifique que el servidor es accesible, escriba una configuración de prueba para el SDK (`~/.twenty/config.test.json`) y sincronice la aplicación antes de que se ejecuten las pruebas:
```ts src/__tests__/global-setup.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
export async function setup() {
const apiUrl = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
// Verify the server is running
const response = await fetch(`${apiUrl}/healthz`);
if (!response.ok) {
throw new Error(`Twenty server is not reachable at ${apiUrl}.`);
}
// Write the SDK's test config (the CLI reads config.test.json when NODE_ENV=test)
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(CONFIG_DIR, 'config.test.json'),
JSON.stringify({
remotes: { local: { apiUrl, apiKey } },
defaultRemote: 'local',
}, null, 2),
);
// Start from a clean slate, then sync the app
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({ appPath: APP_PATH });
if (!result.success) {
throw new Error(`Dev sync failed: ${result.error?.message}`);
}
}
export async function teardown() {
await appUninstall({ appPath: APP_PATH });
}
```
## APIs programáticas del SDK
La subruta `twenty-sdk/cli` exporta funciones que puedes invocar directamente desde el código de pruebas:
| Función | Descripción |
| -------------- | -------------------------------------------------------------------------- |
| `appBuild` | Compilar la aplicación y opcionalmente empaquetar un tarball |
| `appDeploy` | Subir un tarball al servidor |
| `appDevOnce` | Compila y sincroniza la aplicación una vez (igual que `yarn twenty apply`) |
| `appInstall` | Instalar la aplicación en el espacio de trabajo activo |
| `appUninstall` | Desinstalar la aplicación del espacio de trabajo activo |
Cada función devuelve un objeto de resultado con `success: boolean` y `data` o `error`.
## Escribir una prueba de integración
Aquí tienes un ejemplo completo que compila, despliega e instala la aplicación, y luego verifica que aparezca en el espacio de trabajo:
```ts 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();
});
});
```
## Ejecutar pruebas
Asegúrate de que tu servidor local de Twenty esté en ejecución y luego:
```bash filename="Terminal"
yarn test
```
O en modo watch durante el desarrollo:
```bash filename="Terminal"
yarn test:watch
```
## Comprobación de tipos
También puedes ejecutar la comprobación de tipos en tu aplicación sin ejecutar pruebas:
```bash filename="Terminal"
yarn twenty dev:typecheck
```
Esto ejecuta `tsc --noEmit` contra el `tsconfig.json` de tu aplicación e informa cualquier error de tipo. Las aplicaciones generadas también incluyen un script `yarn typecheck` que cubre los archivos de prueba (`tsconfig.spec.json`).
## CI con GitHub Actions
El generador crea un flujo de trabajo listo para usar en `.github/workflows/ci.yml`. En cada push a `main` y en cada pull request, inicia un servidor efímero de Twenty en el runner (mediante la acción `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test`), luego ejecuta `yarn lint`, `yarn typecheck`, `yarn test:unit` y `yarn test` con `TWENTY_API_URL` / `TWENTY_API_KEY` apuntando a ese servidor. No se requieren secretos y puedes fijar la versión del servidor mediante la variable de entorno `TWENTY_VERSION` en la parte superior del flujo de trabajo.
Consulta [Publicación → CI/CD automatizado](/l/es/developers/extend/apps/operations/publishing#automated-cicd-scaffolded-workflows) para ver una guía completa de los tres flujos de trabajo generados (`ci.yml`, la canalización de despliegue `cd.yml` y `publish.yml` para la publicación en npm).

View File

@@ -0,0 +1,131 @@
---
title: Traducciones
description: "Lanza tu aplicación en varios idiomas: traduce las etiquetas del manifiesto y las cadenas de la interfaz de usuario mediante un único catálogo de configuraciones regionales (locales)."
icon: language
---
Las aplicaciones de Twenty están creadas en **inglés**: las cadenas de tu código fuente y del manifiesto son el texto original en inglés, `en` es la configuración regional de origen desde la que traduces y cualquier configuración regional que quede sin traducir volverá a esa configuración.
Tu aplicación tiene dos tipos de texto traducible, y ambos pasan por el mismo catálogo `locales/`:
* **Etiquetas del manifiesto**: nombres de objetos y campos, títulos de vistas, elementos de menú y otras cadenas declaradas en los metadatos de tu aplicación.
* **Cadenas de componentes delanteros** — la interfaz de texto de tus componentes frontales de React.
Marcas las cadenas traducibles, extralas en catálogos por localidad,
traduce esos catálogos, y la construcción sirve el idioma adecuado para el usuario
actual — no hay cableado extra.
## Marcando cadenas de componentes frontales
Importar los ayudantes de traducción de `twenty-sdk/front-component`:
```tsx
import { Trans, t, msg, useTranslate } from 'twenty-sdk/front-component';
const STATUSES = [
{ id: 'draft', label: msg('Draft') },
{ id: 'sent', label: msg('Sent') },
];
const Card = ({ count, name }: { count: number; name: string }) => {
const { t } = useTranslate();
return (
<section>
{/* Static text — reactive to the user's locale */}
<Trans>Loading postcard…</Trans>
{/* Disambiguate identical sources with a context */}
<Trans context="card-title">Untitled</Trans>
{/* Interpolation: pass values explicitly */}
<p>{t('Hi {name}', { name })}</p>
<p>{t('Saved {count} cards', { count })}</p>
{/* Resolve a lazily-declared descriptor */}
<ul>{STATUSES.map((s) => <li key={s.id}>{t(s.label)}</li>)}</ul>
</section>
);
};
```
### Cuándo usar qué
* **`Trans`** — texto estático en JSX. Usa las props `message` y `values` para la interpolación, como en `Trans message="Hi {name}" values={{ name }}`; la interpolación directamente en los children no se puede extraer de forma estática.
* **`useTranslate().t`** — cadenas dinámicas dentro de un componente. Se vuelve a procesar cuando el usuario
cambia de idioma. Preferir esto dentro del render.
* **`t(...)`** (importado directamente) — una traducción entera utilizable **en cualquier lugar**,
incluyendo controladores de eventos, ayudantes y ámbito del módulo — no solo dentro del render.
* **`msg(...)`** — un descriptor perezoso para cadenas declaradas como datos (constantes, configuración
). Resuelva más tarde con `t(descriptor)`.
### Contexto
Pase `context` para desambiguar cadenas de origen idénticas que traduzcan
de forma diferente:
```tsx
t({ message: 'Open', context: 'door' });
t({ message: 'Open', context: 'window' });
<Trans context="card-title">Untitled</Trans>
```
Las cadenas contextuales aparecen en los catálogos de configuración regional como un grupo indexado por su
contexto: `"\<context>": { "\<message>": "\<translation>" }`. Las etiquetas del manifiesto son
contextuales por construcción: cada una lleva el metadato del rol que desempeña (`objectMetadata.labelSingular`, `fieldMetadata.label`, `view.name`, …), por lo que
la misma palabra puede traducirse de manera diferente como nombre de objeto y como nombre de vista.
## Extrayendo y traduciendo
Ejecutar el comando de extracción desde el directorio de la aplicación:
```bash
twenty dev:translations-extract # collect strings into locales/en.json
twenty dev:translations-extract --locale fr-FR # also scaffold a target locale
```
La extracción recopila tanto las etiquetas de tu manifest como las cadenas de `t()`/`msg()`/`Trans` de tu código fuente de front-component en `locales/\<locale>.json`, indexadas por la cadena de origen. Rellena las traducciones:
```json
// locales/fr-FR.json
{
"Loading postcard…": "Chargement de la carte…",
"Hi {name}": "Bonjour {name}",
"Saved {count} cards": "{count} cartes enregistrées",
"door": {
"Open": "Ouvrir"
},
"objectMetadata.labelSingular": {
"Postcard": "Carte postale"
}
}
```
Las cadenas sin contexto son entradas planas; las cadenas contextuales —incluida cada etiqueta de manifiesto— están anidadas bajo su clave de contexto.
Los marcadores de posición como `{name}` se sustituyen en tiempo de ejecución — manténgalos en la traducción
. Cualquier cadena que quede vacía regresa al texto original.
## Cómo funciona
`twenty dev:build` compila los catálogos y sirve el idioma correcto para el
usuario actual: las etiquetas del manifiesto se resuelven del lado del servidor y los catálogos de front-component se incluyen en cada paquete de componente. En tiempo de ejecución, un componente lee la configuración regional
desde su contexto de ejecución (el idioma actual del host) y resuelve
cada cadena contra su catálogo, volviendo a la fuente cuando falta una traducción
. Cambiar el idioma en el host vuelve a renderizar en tiempo real las cadenas de `Trans` y `useTranslate().t`.
Las etiquetas de manifiesto se compilan en cada sincronización — `twenty dev:build`, `twenty apply`
y también en el modo de observación continuo `twenty dev` —, por lo que basta con editar un archivo de configuración regional y
sincronizar para ver el nuevo texto. Los catálogos de componentes del front-end se incorporan en
cada paquete de componentes durante la compilación, por lo que cambiar uno de ellos sigue implicando
volver a ejecutar `twenty dev:build` (y volver a implementar).
Los nodos de texto hijos de `Trans` pueden abarcar varias líneas; los espacios en blanco se colapsan de la misma manera en que JSX los colapsa, por lo que ambos se extraen a la clave `Welcome back`:
```tsx
<Trans>Welcome back</Trans>
<Trans>
Welcome
back
</Trans>
```

View File

@@ -0,0 +1,81 @@
---
title: 5. An AI agent
icon: robot
description: Deja que un agente genere documentos de un chat, usando tu herramienta.
---
Debido a que `generate-document` está expuesto como una **herramienta**, un agente de IA puede llamarlo.
Añadamos un agente y una habilidad para que los usuarios puedan decir *"generar una propuesta para
Grifo de Jeffery"*.
## La habilidad
Una [skill](/l/es/developers/extend/apps/logic/skills-and-agents) es reutilizable
instrucciones: conocimiento que adjuntas a los agentes. La nuestra enseña al modelo cómo usar
la herramienta.
```ts filename="src/skills/document-drafting.skill.ts"
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
name: 'document-drafting',
label: 'Document drafting',
icon: 'IconFileText',
content: [
'To generate a document, call the `generate-document` tool with:',
'- `templateId`: the id of the document template to use.',
'- `recordId`: the id of the Person or Company the document is for.',
'',
'If the user names a template or person instead of an id, find the record first,',
'then pass its id. Make sure the template target matches the record type.',
].join('\n'),
});
```
## El agente
Un [agent](/l/es/developers/extend/apps/logic/skills-and-agents) empareja un símbolo de espera de órdenes con un modelo
. Establece `responseFormat` explícitamente para evitar una advertencia de compilación.
```ts filename="src/agents/document-assistant.agent.ts"
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
name: 'document-assistant',
label: 'Document Assistant',
description: 'Generates documents from your templates and CRM records.',
icon: 'IconFileText',
responseFormat: { type: 'text' },
prompt: [
'You are the Document Assistant for a CRM.',
'You help users generate personalized documents from reusable templates',
'and the data already in their CRM. Use the generate-document tool, and',
'always confirm what you created.',
].join(' '),
});
```
<Note>
El agente sólo puede llamar a la herramienta si su papel lo permite. Ya establecemos
`canAccessAllTools: true` y `canBeAssignedToAgents: true` en el rol de la aplicación en
[Capítulo 2](/l/es/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
</Note>
## Pruébalo
Abre un chat con **Asistente de documentos** y pídele que elabore un documento para una personaformat@@0
en tu CRM. Encuentra el registro, llama a `generate-document` y devuelve
el documento que creó — el cual ahora aparece en tu vista de **Documentos**,
exactamente igual que mediante las rutas del menú de comandos y de los flujos de trabajo.
Esa es la ganancia de exponer la lógica como una herramienta: **una función, muchas puertas delanteras** —
menú de comandos, HTTP, paso de flujo de trabajo y ahora lenguaje natural.
**Después de este paso:** la aplicación es completa y genuinamente útil. Es hora de
enviarlo.
<Card title="Siguiente: publicando →" icon="cohete" href="/Developopers/extend/apps/tutorials/document-generator/publishing">
Añadir metadatos de mercado y publicar.
</Card>

View File

@@ -0,0 +1,307 @@
---
title: 4. Construyendo la interfaz de usuario
icon: table-columns
description: Visualizaciones, navegación en la barra lateral, un comando y componentes frontales.
---
Ahora mismo los objetos sólo son accesibles a través de Configuración. Vamos a dar a la aplicación una presencia
real en la interfaz de usuario: vistas de lista, entradas de la barra lateral, un comando de
**Generar documento**, un componente frontal de página de registro para **previsualizar** un documento
y una pestaña de **editor** de texto nativo para plantillas.
## Vistas y navegación
Una [view](/l/es/developers/extend/apps/layout/views) es una lista guardada de un objeto dado.
Un [elemento del menú de navegación](/l/es/developers/extend/apps/layout/navigation-menu-items)
pone esa vista en la barra lateral. El servidor ya aprovisiona automáticamente la vista predeterminada "All ..." de cada objeto, por lo que las vistas que define una aplicación son adicionales y seleccionadas.
```ts filename="src/views/documents.view.ts"
import { defineView } from 'twenty-sdk/define';
export default defineView({
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All documents',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconFile',
position: 0,
fields: [
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0, isVisible: true, size: 280 },
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1, isVisible: true, size: 120 },
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2, isVisible: true, size: 200 },
],
});
```
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Documents',
icon: 'IconFile',
color: 'green',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
});
```
Añadir el mismo par para las plantillas. Ambos ahora se muestran en la barra lateral:
<Frame caption="Documentos y plantillas en la barra lateral con el documento generado listado.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/04-documents-view.png" alt="Vista de documentos con un documento generado" />
</Frame>
## Un componente frontal
Un [componente delantero](/l/es/developers/extend/apps/layout/front-components) es un componente de React
en el interior de Twenty. Nuestra nuestra lee el registro seleccionado, carga las plantillas
persona a través de `CoreApiClient`, y POSTs a la ruta desde el último capítulo
.
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
const GenerateDocumentForm = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [templateId, setTemplateId] = useState('');
useEffect(() => {
new CoreApiClient()
.query({ documentTemplates: {
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
edges: { node: { id: true, name: true } } } })
.then(({ documentTemplates }) => {
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
setTemplates(list);
if (list[0]) setTemplateId(list[0].id);
});
}, []);
const generate = async () => {
const res = await new RestApiClient().post<{ success: boolean }>(
'/s/documents/generate',
{ templateId, recordId },
);
await enqueueSnackbar({
message: res.success ? 'Document generated.' : 'Generation failed.',
variant: res.success ? 'success' : 'error',
});
};
// ...render a <select> of templates and a Generate button
};
export default defineFrontComponent({
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-document-form',
component: GenerateDocumentForm,
});
```
<Warning>
Estilo con variables CSS en línea (`var(--t-color-blue)`), no valores importados de
`twenty-ui`. El SDK simula ese paquete durante la compilación, por lo que las importaciones a nivel de módulo de
constantes de tema serían `undefined`. Ver el
[componente completo](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
</Warning>
## Un comando para abrirlo
Un [elemento del menú de comandos](/l/es/developers/extend/apps/layout/command-menu-items) con
`availabilityType: 'RECORD_SELECTION'` aparece cuando se selecciona una Persona, y
abre el componente en el panel lateral.
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Generate document',
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
```
## Prueba todo el flujo
Abre **Personas**, marca a una persona y pulsa <kbd>mañK</kbd> / <kbd>Ctrl K</kbd>.
"Generar documento" aparece, etiquetado con tu aplicación:
<Frame caption="El comando se muestra cuando se selecciona una Persona.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/06-command-menu.png" alt="Menú de comandos con Generar documento" />
</Frame>
Ejecutarlo — su componente se abre en el panel lateral. Elige una plantilla, haz clic en
**Generar**, y un nuevo registro de tierras en **Documentos**.
<Frame caption="El componente frontal, cargando plantillas y generando al hacer clic.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/06b-front-component.png" alt="Generar panel lateral del documento" />
</Frame>
Cada documento generado registra tu aplicación como su autor:
<Frame caption="Creado por Document Generator, estado Generado.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/05-document-record.png" alt="Un registro de documento generado" />
</Frame>
## Vista previa de un documento en su página de registro
Un componente frontal no solo para los menús de comandos: puedes montar uno como una \*\*pestaña en una página de registro
. Añadamos una pestaña de *Vista previa* al registro de documentos que renderiza el cuerpo de Markdown
como una página pulida e imprimible.
El componente lee el id de registro actual de su contexto de ejecución, carga el documento
y lo renderiza. Los componentes de Front se ejecutan en una **sandbox** que solo permite una
lista blanca de etiquetas HTML: la inyección de HTML sin procesar (`dangerouslySetInnerHTML`) y
`\<style>` están bloqueados, por lo que representamos el Markdown como elementos de React con estilos
inline mediante un pequeño helper [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx).
```tsx filename="src/front-components/document-viewer.front-component.tsx"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { Markdown } from 'src/utils/markdown-to-react';
const DocumentViewer = () => {
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
// ...load { content, file } for recordId, then derive the links:
const pdfUrl = document.file?.[0]?.url;
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
query: { id: recordId },
});
// Render the template body, plus quick links to the web page and the PDF.
// Links open in a new tab so they don't navigate the embedded component.
return (
<div style={styles.scroll}>
<div style={styles.actions}>
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
Open web page
</a>
{pdfUrl ? (
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
Download PDF
</a>
) : null}
</div>
<div style={styles.paper}>
<div style={styles.body}>
<Markdown content={document.content} />
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'document-viewer',
component: DocumentViewer,
});
```
Montarlo con un [diseño de página](/l/es/developers/extend/apps/layout/page-layouts). Un diseño de
`RECORD_PAGE` agrega pestañas a la vista de registro de un objeto; una pestaña con un solo widget
`FRONT_COMPONENT` aloja el componente a todo lo ancho:
```ts filename="src/page-layouts/document-record.page-layout.ts"
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Document record page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
icon: 'IconEye',
position: 50,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Document preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
}],
}],
});
```
Abre cualquier documento — una pestaña de **Vista previa** lo renderiza hermosamente, con enlaces a la página web compartible
y el PDF:
<Frame caption="La pestaña Vista previa muestra el documento con estilos en línea, además de enlaces rápidos.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/09-document-viewer.png" alt="Componente frontal del visor de documentos en una pestaña de página de registro" />
</Frame>
## Editar una plantilla con el editor de texto
Las plantillas no necesitan ningún componente personalizado. Porque el `body` es un campo
`RICH_TEXT`, Veinte ya proporciona un editor de texto completo para él — el mismo
que los objetos estándar de Nota y Task usan. Acabamos de superarlo en la página de registro
plantilla.
Añade una pestaña con un widget `FIELD` en el modo de visualización `EDITOR`, apuntando al campo `body`
a través de `fieldMetadataId`:
```ts filename="src/page-layouts/template-record.page-layout.ts"
{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Template',
position: 1,
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Template',
type: 'FIELD',
position: { layoutMode: PageLayoutTabLayoutMode.GRID, row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
configuration: {
configurationType: 'FIELD',
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'EDITOR',
},
}],
}
```
Un campo `RICH_TEXT` almacena tanto el bloque JSON del editor como una proyección
de Markdown. La generación de pipeline lee que Markdown proyecta, así que
marcadores de posición, el PDF, y la página web compartible siguen funcionando sin cambios —
vea el
completo [`template-record. age-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
Los widgets de `FIELD` también admiten otros modos de visualización y opciones, incluido
listar registros de una relación anidada a dos saltos de distancia — consulta la
[referencia del widget de campo](/l/es/developers/extend/apps/layout/page-layouts#field-widgets).
Ahora editores escriben plantillas en un editor de texto rich:
<Frame caption="La pestaña Plantilla: Editor nativo de texto de texto de 20 años vinculado al campo del cuerpo.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/10-template-editor.png" alt="Registro de plantillas con la pestaña nativa del editor de texto" />
</Frame>
**Después de este paso:** vista previa de documentos hermosamente y plantillas son editables
en la aplicación. A continuación, deja que un agente de IA los genere a partir de un chat.
<Card title="Siguiente: un agente de IA →" icon="robot" href="/Developopers/extend/apps/tutorials/document-generator/ai-agent">
Añade un agente y una habilidad que llame a tu herramienta.
</Card>

View File

@@ -0,0 +1,135 @@
---
title: 1. Modelo de datos
icon: database
description: Modelo de documentos y plantillas con objetos, campos y una relación.
---
Nuestra aplicación necesita dos objetos personalizados: **plantillas de documentos** (qué escribir) y
**documentos** (el resultado generado). Vamos a definirlos.
Escaffold cada archivo de entidad con la CLI — genera un UUID válido y la carpeta
correcta para ti:
```bash filename="Terminal"
yarn twenty dev:add object
```
A continuación mostramos los archivos terminados.
<Note>
Cada `*_UNIVERSAL_IDENTIFIER` vida constante en
`src/constants/universal-identifiers.ts` y es importado donde se utiliza. Los fragmentos
a continuación omiten las importaciones por brevedad — manténgalos en tus propios archivos.
</Note>
## El objeto de plantilla
Una plantilla tiene un `nombre`, un `cuerpo` con `{{placeholders}}`, y un `target` que
dice si está escrito para una persona o una empresa. El `body` es un campo
`RICH_TEXT`, así que Veinte le da un editor completo de texto.
```ts filename="src/objects/document-template.object.ts"
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'documentTemplate',
namePlural: 'documentTemplates',
labelSingular: 'Document template',
labelPlural: 'Document templates',
icon: 'IconFileText',
labelIdentifierFieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
defaultValue: `'PERSON'`,
options: [
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
] },
],
});
```
<Warning>
`SELECT` option **values** debe ser `UPPER_CASE` (`PERSON`, no `person`), y el
`defaultValue` está envuelto en comillas adicionales: `` `'PERSON'` ``. La `label` es lo que
los usuarios ven.
</Warning>
## El objeto del documento
El documento generado almacena el `contenido` renderizado y un `status`. Definirlo
de la misma manera, con un `status` seleccionado de `DRAFT` / `GENERATED`. Archivo completo:
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
## Vinculándolas con una relación
Cada documento debe apuntar de nuevo a la plantilla de la que proviene. Las relaciones son
**bidireccionales** — defines ambos lados, cada uno en su propio archivo de campo.
```ts filename="src/fields/document-template-relation.field.ts"
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
// The "many" side: each document belongs to one template.
export default defineField({
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'template',
label: 'Template',
relationTargetObjectMetadataUniversalIdentifier:
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'templateId',
},
});
```
El otro lado (`template-documents-relation.field.ts`) es un campo
`RelationType.ONE_TO_MANY` llamado `documents` que apunta a la manera opuesta.
Ver [Relations](/l/es/developers/extend/apps/data/relations) para ver el patrón completo.
## Ver en Veinte
Con `yarn veinte dev` en ejecución, abre **Ajustes → Modelo de datos**. Ambos objetos
aparecen, etiquetados con tu aplicación.
<Frame caption="Ambos objetos personalizados, propiedad del generador de documentos.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/01-data-model.png" alt="Configuración del modelo de datos mostrando plantillas de documentos y documentos" />
</Frame>
Crea una plantilla para probar con — nombra la *propuesta de ventas*, establece el **objetivo** a
*Persona*, y pega un cuerpo con unos pocos marcadores de posición:
```text
Dear {{name.firstName}} {{name.lastName}},
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
Best,
The Team
```
<Frame caption="Un registro de plantilla. El cuerpo mantiene sus marcadores de posición hasta que se genere un documento.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/03-template-record.png" alt="Un registro de plantilla de propuesta de ventas con el cuerpo del marcador de posición" />
</Frame>
**Después de este paso:** tienes los objetos `documentTemplate` y `document`, enlazados por
una relación, y una plantilla para generar. Después, la lógica que lo llena.
<Card title="Siguiente: generando documentos →" icon="bolt" href="/developopers/extend/apps/tutorials/document-generator/generating-documents">
Escriba la función lógica que llena la plantilla.
</Card>

View File

@@ -0,0 +1,239 @@
---
title: 2. Generando documentos
icon: bolt
description: Una función lógica, expuesta como una herramienta de IA y una acción de flujo de trabajo.
---
Ahora el núcleo: una [función lógica](/l/es/developers/extend/apps/logic/logic-functions)
que carga una plantilla y un registro, rellena los marcadores de posición y guarda un nuevo documento
.
Escribiremos la lógica de negocio una sola vez como un **handler**, y luego la expondremos mediante
diversos desencadenadores. Este capítulo conecta dos de ellos: una **herramienta de IA** y una
**acción de flujo de trabajo**.
## El ayudante de renderizado
Mantenga la lógica pura en su propio archivo para que sea fácil de unir-test. Esto arrastra un registro
en `{{dot.path}}` tokens y los sustituye.
```ts filename="src/logic-functions/utils/render-template.ts"
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
export const renderTemplate = (body: string, values: Record<string, string>) => {
const missingTokens = new Set<string>();
const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
const value = values[token];
if (value === undefined || value === '') { missingTokens.add(token); return ''; }
return value;
});
return { content, missingTokens: [...missingTokens] };
};
```
<Tip>
Debido a que este archivo no tiene efectos secundarios, puedes cubrirlo con pruebas unitarias rápidas
(`yarn test:unit`). Ver [Testing](/l/es/developers/extend/apps/operations/testing).
</Tip>
## El manejador
El manejador utiliza el [`CoreApiClient`](/l/es/developers/extend/apps/logic/logic-functions)
generado para leer y escribir datos CRM. Carga la plantilla, carga el registro de destino, rellena
el cuerpo y crea un `document`.
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
import { renderTemplate } from 'src/logic-functions/utils/render-template';
export const generateDocumentHandler = async (
input: { templateId: string; recordId: string },
) => {
const client = new CoreApiClient();
// Use a filtered list query, not the singular lookup: the singular query
// throws when nothing matches, which would become a 500 instead of a 404.
const { documentTemplates } = await client.query({
documentTemplates: {
__args: { filter: { id: { eq: input.templateId } }, first: 1 },
edges: { node: { id: true, name: true, body: true, target: true } },
},
});
const documentTemplate = documentTemplates?.edges?.[0]?.node;
if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };
const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
if (!record.found) return { success: false, status: 404, message: 'Record not found.' };
const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);
const { createDocument } = await client.mutation({
createDocument: {
__args: { data: {
name: `${documentTemplate.name} — ${record.displayName}`,
content, status: 'GENERATED', templateId: documentTemplate.id,
} },
id: true, name: true,
},
});
return { success: true, documentId: createDocument.id, content, missingTokens };
};
```
`loadRecordValues` ejecuta una consulta diferente para una Persona vs. una Empresa y aplana
el resultado — vea
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts).
## Exponerlo como una herramienta y una acción de flujo de trabajo
Un solo `defineLogicFunction` puede llevar varios disparadores. Aquí, `toolTriggerSettings`
hace que sea llamable por agentes IA, y `workflowActionTriggerSettings` lo convierte en un paso
en el constructor de flujo de trabajo visual. Ambos describen su entrada con un esquema JSON.
```ts filename="src/logic-functions/generate-document.ts"
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'generate-document',
description: 'Generate a document from a template and a CRM record.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: generateDocumentInputSchema,
},
workflowActionTriggerSettings: {
label: 'Generate Document',
icon: 'IconFileText',
inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
outputSchema: [{ type: 'object', properties: {
success: { type: 'boolean' }, documentId: { type: 'string' },
} }],
},
handler: generateDocumentHandler,
});
```
El esquema de entrada es un esquema JSON simple que describe `templateId` y `recordId` —
see [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts).
## Conceder acceso
Funciones lógicas ejecutadas como el rol de la aplicación. Necesita leer plantillas y registrar
y crear documentos, así que permita eso en `src/roles/default-role.ts`:
```ts
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Document Generator default role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canAccessAllTools: true,
canBeAssignedToAgents: true,
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});
```
`UPLOAD_FILE` permite a la función cargar el PDF generado en la siguiente sección.
Ver [Roles](/l/es/developers/extend/apps/config/roles) para obtener permisos más finos.
## Adjuntar un archivo PDF real
Un campo de texto renderizado es útil, pero los usuarios quieren un documento real. Vamos a generar un
**PDF** y almacenarlo en el registro como un archivo descargable.
Primero, da al objeto `document` un campo `ARCHIVOS` para mantener el PDF. Las aplicaciones suben
a sus campos de archivos **propios**, así que este campo es qué rutas cargar:
```ts filename="src/objects/document.object.ts"
{
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'file',
label: 'File',
icon: 'IconFileTypePdf',
universalSettings: { maxNumberOfValues: 1 },
}
```
Ahora renderice ese PDF. Una aplicación es un proyecto de nodo real, así que puedes añadir cualquier paquete npm
que necesites e importarlo como en cualquier otro lugar. Utilizamos **[pdf-lib](https://pdf-lib.js.org/)**
para dibujar el PDF y **[marked](https://marked.js.org/)** para analizar el cuerpo de Markdown
— el CLI los instala en el tiempo de ejecución de la función para ti:
```bash filename="Terminal"
yarn add pdf-lib marked
```
El ayudante completo es
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts).
Analiza el Markdown en tokens con `marked.lexer`, y luego los dispone con
pdf-lib: encabezados reales, texto en **negrita**/*cursiva*, listas con viñetas y numeradas,
citas en bloque y líneas de separación: una representación A4 pulida y de varias páginas de la propia plantilla,
en lugar de un bloque de texto.
<Frame caption="El PDF generado: tipografía real y formato Markdown, renderizando el cuerpo de la plantilla.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/07b-generated-pdf.png" alt="Un PDF pulido y generado por marcadores" />
</Frame>
<Note>
Las fuentes incorporadas de pdf-lib's usan la codificación WinAnsi, por lo que los acentos de WesternEuropean renderizan
fuera de la caja el ayudante mapea comillas inteligentes y guiones y deja caer caracteres que
no puede codificar. Renderizar escrituras no latinas (chino, árabe, cirílico) implicaría
incrustar una fuente Unicode.
</Note>
A continuación, suba y almacene la referencia en el registro. `uploadFile` rutas bytes
a tu campo de archivos propiedad de la aplicación; el `id` devuelto es lo que guardas:
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';
const documentName = `${documentTemplate.name} — ${record.displayName}`;
const bytes = await generateDocumentPdf(documentName, content);
const fileName = 'proposal.pdf';
const uploaded = await new MetadataApiClient().uploadFile(
Buffer.from(bytes),
fileName,
'application/pdf',
DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
await client.mutation({
updateDocument: {
__args: {
id: documentId,
data: { file: [{ fileId: uploaded.id, label: fileName }] },
},
id: true,
},
});
```
El documento generado ahora contiene un PDF descargable:
<Frame caption="El PDF generado, almacenado en el campo Archivo del documento.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/08-document-with-pdf.png" alt="Un registro de documento con un archivo PDF generado" />
</Frame>
<Note>
`uploadFile` solo se dirige a campos de archivos **propiedad de la app** (por lo que las cargas siempre requieren una
app que sea propietaria del campo, además del indicador de rol `UPLOAD_FILE`). Por eso el PDF
aterriza en el propio campo `file` del registro — el mismo patrón que el
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
usa para grabaciones.
</Note>
**Después de este paso:** cada documento generado tiene un PDF real y descargable. Pero
nada puede *llamar* al generador de la interfaz de usuario aún — para eso necesitamos una ruta HTTP.
<Card title="Siguiente: rutas HTTP →" icon="globo" href="/Developopers/extend/apps/tutorials/document-generator/http-routes">
Servir la función sobre HTTP y representar documentos como páginas web.
</Card>

View File

@@ -0,0 +1,150 @@
---
title: 3. Rutas HTTP
icon: globe
description: Activa la función sobre HTTP y renderiza documentos como páginas web.
---
El mismo manejador también puede responder a peticiones HTTP. Añadiremos dos rutas:
* un endpoint **POST** para generar un documento, y
* un endpoint público **GET** que renderiza un documento como una página web imprimible.
Ambos usan `httpRouteTriggerSettings`. Las rutas de la aplicación se encuentran bajo la ruta `/s/`; en las instancias locales
y autoalojadas, esa ruta la sirve tu servidor de Twenty
(p. ej., `http://localhost:2020/s/documents/generate`). En Twenty Cloud, `RestApiClient`
resuelve la ruta a través del dominio de funciones del espacio de trabajo.
## Ruta POST — generar bajo demanda
Esto reutiliza `generateDocumentHandler`, así que no hay lógica para repetir: solo un adaptador
fino que lee el cuerpo de la solicitud.
```ts filename="src/logic-functions/generate-document-route.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
const handler = async (event: RoutePayload): Promise<Response> => {
const body = event.body as Record<string, unknown> | null;
const result = await generateDocumentHandler({
templateId: (body?.templateId as string) ?? '',
recordId: (body?.recordId as string) ?? '',
});
// Map the handler's failure reason onto a real HTTP status (400/404/500)
// instead of always returning 200.
return new Response(JSON.stringify(result), {
status: result.success ? 200 : (result.status ?? 400),
headers: { 'Content-Type': 'application/json' },
});
};
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'generate-document-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/documents/generate',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
El manejador compartido devuelve un `status` sugerido en caso de fallo, así que la ruta puede
responder con un código apropiado `4xx`/`5xx`. `isAuthRequired: true` significa que la persona que llama
debe presentar un token válido — el componente frontal en el siguiente capítulo pasa el token de acceso del usuario
automáticamente.
## Ruta GET — renderizar como una página web
Para devolver HTML en lugar de JSON, envuelve el cuerpo en un encabezado `Response` con una cabecera
`Content-Type`. Esta ruta es pública (`isAuthRequired: false`) por lo que un documento generado
puede ser compartido como un enlace.
```ts filename="src/logic-functions/view-document.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { documentHtmlPage } from 'src/utils/render-document';
const htmlResponse = (html: string, status = 200): Response =>
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
const handler = async (event: RoutePayload): Promise<Response> => {
const documentId = event.queryStringParameters?.id;
if (!documentId) {
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
}
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
const { documents } = await new CoreApiClient().query({
documents: {
__args: { filter: { id: { eq: documentId } }, first: 1 },
edges: { node: { id: true, name: true, content: true } },
},
});
const document = documents?.edges?.[0]?.node;
if (!document?.id) {
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
}
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
};
export default defineLogicFunction({
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'view-document',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/documents/view',
httpMethod: 'GET',
isAuthRequired: false,
},
});
```
`documentHtmlPage` renderiza el cuerpo de Markdown a HTML (con [marked](https://marked.js.org/),
saneado) y lo deja caer en una limpieza, página imprimible que muestra sólo el contenido
plantilla — el mismo aspecto que el PDF y la vista previa dentro de la aplicación.
[Ver el ayudante](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
## Pruébalo
Con una plantilla y una persona en tu espacio de trabajo, llama a la ruta (toma un token desde
**Ajustes → APIs & Webhooks**):
```bash filename="Terminal"
curl -X POST http://localhost:2020/s/documents/generate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
```
Abra el documento devuelto en su navegador:
```
http://localhost:2020/s/documents/view?id=<documentId>
```
<Frame caption="La ruta pública GET renderiza el documento como una página imprimible.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/07-rendered-document.png" alt="Una página web de documentos procesados" />
</Frame>
<Tip>
También puedes transmitir los registros de una función mientras pruebas con
`yarn twenty dev:function:logs`, o invocarlo directamente con
`yarn twenty dev:function:exec`.
</Tip>
**Después de este paso:** la aplicación puede generar documentos a través de HTTP y servirlos como
páginas web. Ahora hagámoslo utilizable sin `curl`.
<Card title="Siguiente: construyendo la interfaz de usuario →" icon="table-columns" href="/Developopers/extend/apps/tutorials/document-generator/building-the-ui">
Vistas, navegación, un comando y un componente frontal.
</Card>

View File

@@ -0,0 +1,67 @@
---
title: "Tutorial: Document Generator"
icon: wand-magic-sparkles
description: Crea una aplicación real de Twenty que genere documentos personalizados a partir de los datos de tu CRM.
---
En este tutorial crearás **Document Generator**, una aplicación que convierte plantillas reutilizables en documentos personalizados usando los datos que ya tienes en tu CRM.
Escribe una plantilla una vez con `{{placeholders}}`, luego genera un documento completado para cualquier Persona o Empresa con un solo clic, desde el menú de comandos, desde un agente de IA o desde un flujo de trabajo.
<Frame caption="Una plantilla, generada para una persona específica, abierta como una página imprimible.">
<img src="/images/docs/desarrolladores/extends/apps/document-generator/07-rendered-document.png" alt="Un documento generado de propuesta de ventas" />
</Frame>
## Lo que aprenderás
Cada capítulo añade una capacidad. Al final habrás tocado la mayoría del SDK.
| Capítulo | Capacidad | Referencia |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| [1. Modelo de datos](/l/es/developers/extend/apps/tutorials/document-generator/data-model) | Objetos, campos y una relación | [Data](/l/es/developers/extend/apps/data/overview) |
| [2. Generando documentos](/l/es/developers/extend/apps/tutorials/document-generator/generating-documents) | Una función lógica (herramienta (AI + acción de flujo de trabajo) que rellena una plantilla de Markdown y adjunta un PDF pulido | [Funciones lógicas](/l/es/developers/extend/apps/logic/logic-functions) |
| [3. Rutas HTTP](/l/es/developers/extend/apps/tutorials/document-generator/http-routes) | Ejecutando JSON y una página HTML compartible desde rutas | [Funciones lógicas](/l/es/developers/extend/apps/logic/logic-functions) |
| [4. Construir la interfaz de usuario](/l/es/developers/extend/apps/tutorials/document-generator/building-the-ui) | Vistas, navegación, menú de comandos y componentes frontales que previsualizan un documento y editan una plantilla | [Diseño](/l/es/developers/extend/apps/layout/overview) |
| [5. An AI agent](/l/es/developers/extend/apps/tutorials/document-generator/ai-agent) | Agente + habilidad | [Habilidades y agentes](/l/es/developers/extend/apps/logic/skills-and-agents) |
| [6. Publicación](/l/es/developers/extend/apps/tutorials/document-generator/publishing) | Envíalo al mercado | [Publicación](/l/es/developers/extend/apps/operations/publishing) |
## Prerrequisitos
Deberías haber terminado el [Inicio rápido](/l/es/developers/extend/apps/getting-started/quick-start):
un servidor Veinte local corriendo en el puerto `2020` y el CLI autenticado en él.
Si no, andamio e inicia uno ahora:
```bash filename="Terminal"
npx create-twenty-app@latest document-generator
cd document-generator
yarn twenty dev
```
<Note>
¿Prefieres leer el código terminado? La aplicación completa vive en
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
Cada fragmento de abajo es copiado de él.
</Note>
## Cómo encaja la aplicación
<Frame>
<img src="/images/docs/desarrolladores/extends/apps/document-generator/how-it-fits.svg" alt="Una plantilla con marcadores de posición se genera en un documento pulido con un PDF, activado desde el menú de comandos, un agente IA, un flujo de trabajo o un enlace compartible" />
</Frame>
Escribes una **plantilla** una vez en un editor de texto rich, con `{{placeholders}}`. Al elegir una plantilla
y un registro CRM rellenan los marcadores de posición y almacenan un **documento** pulido
(con un archivo PDF). Todo lo demás — el menú de comandos, el agente IA,
el paso del flujo de trabajo, el enlace compartible — es sólo una manera diferente de activar que
un generador.
## Mantener este ciclo en ejecución
Deja `yarn twenty dev` corriendo en un terminal para el tutorial completo. Cada vez que
agregas o editas un archivo en `src/`, se vuelve a sincronizar con tu servidor en unos
segundos, para que puedas ver cómo cada capacidad aparece en la interfaz de usuario a medida que la construyes.
<Card title="Comenzar construcción →" icon="database" href="/Developopers/extend/apps/tutorials/document-generator/data-model">
Capítulo 1: documentos de modelo y plantillas.
</Card>

View File

@@ -0,0 +1,135 @@
---
title: 6. Publicación
icon: rocket
description: Añade metadatos de mercado y publica tu aplicación.
---
Tu app funciona. El último paso es describirlo para el mercado y publicarlo.
## Añadir metadatos de mercado
La [configuración de la aplicación](/l/es/developers/extend/apps/config/application) lleva los enlaces
que se muestran en el mercado: autor, categoría, logotipo y soporte
. Coloca un logotipo en `public/` y haz referencia a él con `logo`.
```ts filename="src/application-config.ts"
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Document Generator',
description:
'Create reusable document templates and generate personalized documents from your CRM records.',
logo: 'public/document-generator.svg',
author: 'Twenty',
category: 'Productivity',
websiteUrl: 'https://docs.twenty.com/l/es/developers/extend/apps',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
});
```
<Tip>
El rol por defecto se declara con `defineApplicationRole()` en su propio archivo — usted
ya no pase `defaultRoleUniversalIdentifier` aquí.
</Tip>
También añade la palabra clave `twenty-app` a `package.json` para que la aplicación sea detectable:
```json filename="package.json"
{ "keywords": ["twenty-app"] }
```
## Añadir capturas de pantalla de galería
Un listado de mercado se vende a sí mismo con capturas de pantalla. Coloca algunos PNG en `public/gallery/` y haz referencia a ellos con `galleryImages` — se muestran como una galería en la página de listado, en el orden del array.
```ts filename="src/application-config.ts"
export default defineApplication({
// ...identity from above
galleryImages: [
'public/gallery/01-generated-document.png',
'public/gallery/02-command-menu.png',
'public/gallery/03-template-editor.png',
'public/gallery/04-documents.png',
],
});
```
<Tip>
Lance con la ganancia: haga la primera captura de pantalla el resultado terminado (un documento
generado), luego muestre cómo se activa y se autoriza. Usa capturas
nítidas y de alta resolución, son lo primero que un usuario ve.
</Tip>
Dale a `README.md` el mismo tratamiento: es la página principal de npm y GitHub.
Abre con la proposición de valor y una captura de pantalla, muestra las características de la línea de cabecera,
y mantén los detalles de construcción debajo del plano.
## Comprobar antes de enviar
Ejecuta las mismas puertas que CI hace:
```bash filename="Terminal"
yarn lint # oxlint
yarn typecheck # tsgo
yarn test:unit # unit tests
yarn twenty plan # preview the metadata diff
```
El plan muestra exactamente qué cambiaría en el servidor sin aplicarlos —
una buena comprobación final. Ver
[Testing](/l/es/developers/extend/apps/operations/testing) y
[Sincronizando y recuperando](/l/es/developers/extend/apps/operations/sync-and-recovery).
## Publicar
```bash filename="Terminal"
# Public app → npm (default)
yarn twenty app:publish
# Or deploy privately to a specific server's registry
yarn twenty app:publish --private -r <remote>
```
`app:publish` construye y publica a npm por defecto; `--private` sube un tarball
a un registro privado del servidor Veinte en su lugar. Para superficiar una aplicación publicada
en el mercado de una instancia, dispara una sincronización de catálogo:
```bash filename="Terminal"
yarn twenty dev:catalog-sync -r <remote>
```
Detalles completos y la lista de verificación de lanzamiento:
[Publishing](/l/es/developers/extend/apps/operations/publishing).
## Construiste una aplicación 🎉
En seis capítulos utilizaste la mayor parte de la superficie SDK:
* **Objetos, campos y una relación** para modelar los datos
* Una **función lógica** expuesta como una **herramienta de IA**, una **acción de flujo de trabajo**, y **rutas HTTP**
* **Vistas, navegación, un comando y un componente frontal** para la interfaz de usuario
* Un **agente + habilidad** para generación natural
* **Metadatos del mercado** y el flujo de publicación
La aplicación terminada está en
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
## A dónde ir siguiente
<CardGroup cols={2}>
<Card title="Referencia de datos" icon="database" href="/l/es/developers/extend/apps/data/overview">
Cada tipo de campo, relación y opción de índice.
</Card>
<Card title="Referencia lógica" icon="bolt" href="/l/es/developers/extend/apps/logic/overview">
Activadores de eventos Cron y base de datos, el almacén clave-valor, conexiones OAuth.
</Card>
<Card title="Referencia del diseño" icon="table-columns" href="/l/es/developers/extend/apps/layout/overview">
Diseños de páginas, widgets de tablero y más superficies de interfaz.
</Card>
<Card title="Operaciones" icon="rocket" href="/l/es/developers/extend/apps/operations/overview">
CLI, prueba, control remoto, y CI.
</Card>
</CardGroup>

View File

@@ -0,0 +1,147 @@
---
title: APIs
description: Consulta y modifica tus datos de CRM de forma programática usando REST o GraphQL.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Twenty fue creado para ser amigable con los desarrolladores, ofreciendo APIs potentes que se adaptan a tu modelo de datos personalizado. Proveemos cuatro tipos de API distintos para satisfacer diferentes necesidades de integración.
## Enfoque centrado en el desarrollador
Twenty genera APIs específicamente para tu modelo de datos:
* **No se requieren IDs largos**: Usa los nombres de tus objetos y campos directamente en los endpoints.
* **Objetos estándar y personalizados tratados por igual**: Tus objetos personalizados reciben el mismo tratamiento de API que los incorporados.
* **Endpoints dedicados**: Cada objeto y campo recibe su propio endpoint de API.
* **Documentación personalizada**: Generada específicamente para el modelo de datos de tu espacio de trabajo.
<Note>
Tu documentación personalizada de la API está disponible en **Configuración → API & Webhooks** después de crear una clave de API. Como Twenty genera APIs que coinciden con tu modelo de datos personalizado, la documentación es única para tu espacio de trabajo.
</Note>
## Los dos tipos de API
### API Principal
Accesible en `/rest/` o `/graphql/`
Trabaja con tus **registros** reales (los datos):
* Crear, leer, actualizar y eliminar Personas, Empresas, Oportunidades, etc.
* Consultar y filtrar datos
* Gestionar relaciones de registros
### API de Metadatos
Accesible en `/rest/metadata/` o `/metadata/`
Administra tu **espacio de trabajo y modelo de datos**:
* Crear, modificar o eliminar objetos y campos
* Configurar ajustes del espacio de trabajo
* Define relaciones entre objetos
## REST vs GraphQL
Tanto las API Core como las de Metadatos están disponibles en formatos REST y GraphQL:
| Formato | Operaciones disponibles |
| ----------- | ----------------------------------------------------------------------------- |
| **REST** | CRUD, operaciones por lotes, upserts |
| **GraphQL** | Lo mismo + **upserts por lotes**, consultas de relaciones en una sola llamada |
Elige según tus necesidades — ambos formatos acceden a los mismos datos.
## Puntos de Acceso de API
| Entorno | URL base |
| ------------------- | ------------------------- |
| **Nube** | `https://api.twenty.com/` |
| **Autoalojamiento** | `https://{your-domain}/` |
## Autenticación
Cada solicitud a la API requiere una clave de API en el encabezado:
```
Authorization: Bearer YOUR_API_KEY
```
### Crear una Clave de API
1. Ve a **Configuración → APIs y Webhooks**
2. Haz clic en **+ Crear clave**
3. Configurar:
* **Nombre**: Nombre descriptivo para la clave
* **Fecha de vencimiento**: Cuándo expira la clave
4. Haga clic en **Guardar**
5. **Copia de inmediato** — la clave solo se muestra una vez
<VimeoEmbed videoId="928786722" title="Creación de clave de API" />
<Warning>
Tu clave de API concede acceso a datos sensibles. No la compartas con servicios no confiables. Si se ve comprometida, desactívala de inmediato y genera una nueva.
</Warning>
### Asignar un rol a una clave de API
Para mayor seguridad, asigna un rol específico para limitar el acceso:
1. Ve a **Ajustes → Miembros → Roles**
2. Haz clic en el rol que deseas asignar
3. Abre la pestaña **Asignación**
4. En **Claves de API**, haz clic en **+ Asignar a clave de API**
5. Selecciona la clave de API
La clave heredará los permisos de ese rol. Consulta [Permisos](/l/es/user-guide/permissions-access/capabilities/permissions) para más detalles.
### Gestionar Claves de API
**Regenerar**: Configuración → APIs & Webhooks → Haz clic en la clave → **Regenerar**
**Eliminar**: Configuración → APIs & Webhooks → Haz clic en la clave → **Eliminar**
## Playground de la API
Prueba tus API directamente en el navegador con nuestro playground integrado — disponible tanto para **REST** como para **GraphQL**.
### Accede al Playground
1. Ve a **Configuración → APIs y Webhooks**
2. Crea una clave de API (obligatorio)
3. Haz clic en **REST API** o **GraphQL API** para abrir el playground
### Lo que obtienes
* **Documentación interactiva**: Generada para tu modelo de datos específico
* **Pruebas en vivo**: Ejecuta llamadas reales a la API en tu espacio de trabajo
* **Explorador de esquemas**: Navega por los objetos, campos y relaciones disponibles
* **Constructor de solicitudes**: Crea consultas con autocompletado
El playground refleja tus objetos y campos personalizados, por lo que la documentación siempre es precisa para tu espacio de trabajo.
## Operaciones por Lotes
Tanto REST como GraphQL admiten operaciones por lotes:
* **Tamaño del lote**: Hasta 60 registros por solicitud
* **Operaciones**: Crear, actualizar y eliminar múltiples registros
**Características exclusivas de GraphQL:**
* **Upsert por lotes**: Crear o actualizar en una llamada
* Usa nombres de objetos en plural (por ejemplo, `CreateCompanies` en lugar de `CreateCompany`)
## Límites de tasa
Las solicitudes a la API se limitan para garantizar la estabilidad de la plataforma:
| Límite | Valor |
| ------------------- | -------------------------- |
| **Solicitudes** | 100 solicitudes por minuto |
| **Tamaño del lote** | 60 registros por llamada |
<Tip>
Usa operaciones por lotes para maximizar el rendimiento — procesa hasta 60 registros en una sola llamada a la API en lugar de hacer solicitudes individuales.
</Tip>

View File

@@ -0,0 +1,112 @@
---
title: Webhooks
description: Recibe notificaciones en tiempo real cuando ocurran eventos en tu CRM.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Los webhooks envían datos a tus sistemas en tiempo real cuando ocurren eventos en Twenty — no se requiere sondeo. Úsalos para mantener sincronizados los sistemas externos, activar automatizaciones o enviar alertas.
## Crear un Webhook
1. Ve a **Configuración → APIs y Webhooks → Webhooks**
2. Haga clic en **+ Crear webhook**
3. Introduce la URL de tu webhook (debe ser públicamente accesible)
4. Haga clic en **Guardar**
El webhook se activa de inmediato y comienza a enviar notificaciones.
<VimeoEmbed videoId="928786708" title="Crear un webhook" />
### Gestionar Webhooks
**Editar**: Haz clic en el webhook → Actualizar la URL → **Guardar**
**Eliminar**: Haz clic en el webhook → **Eliminar** → Confirmar
## Eventos
Twenty envía webhooks para estos tipos de eventos:
| Evento | Ejemplo |
| ---------------------------- | ---------------------------------------------------------- |
| **Se crea un registro** | `person.created`, `company.created`, `note.created` |
| **Se actualiza un registro** | `person.updated`, `company.updated`, `opportunity.updated` |
| **Se elimina un registro** | `person.deleted`, `company.deleted` |
Todos los tipos de eventos se envían a la URL de tu webhook. Es posible que se agregue el filtrado de eventos en versiones futuras.
## Formato de la carga útil
Cada webhook envía una solicitud HTTP POST con un cuerpo JSON:
```json
{
"event": "person.created",
"data": {
"id": "abc12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"createdAt": "2025-02-10T15:30:45Z",
"createdBy": "user_123"
},
"timestamp": "2025-02-10T15:30:50Z"
}
```
| Campo | Descripción |
| ----------------- | -------------------------------------------------- |
| `evento` | Qué ocurrió (p. ej., `person.created`) |
| `datos` | El registro completo que se creó/actualizó/eliminó |
| `marca de tiempo` | Cuándo ocurrió el evento (UTC) |
<Note>
Responde con un **estado HTTP 2xx** (200-299) para confirmar la recepción. Las respuestas que no sean 2xx se registran como errores de entrega.
</Note>
## Validación de Webhook
Twenty firma cada solicitud de webhook por seguridad. Valida las firmas para garantizar que las solicitudes sean auténticas.
### Encabezados
| Encabezado | Descripción |
| ---------------------------- | ------------------------------- |
| `X-Twenty-Webhook-Signature` | Firma HMAC SHA256 |
| `X-Twenty-Webhook-Timestamp` | Marca de tiempo de la solicitud |
### Pasos de validación
1. Obtén la marca de tiempo de `X-Twenty-Webhook-Timestamp`
2. Crea la cadena: `{timestamp}:{JSON payload}`
3. Calcula HMAC SHA256 usando tu secreto de webhook
4. Compara con `X-Twenty-Webhook-Signature`
### Ejemplo (Node.js)
```javascript
const crypto = require("crypto");
const timestamp = req.headers["x-twenty-webhook-timestamp"];
const payload = JSON.stringify(req.body);
const secret = "your-webhook-secret";
const stringToSign = `${timestamp}:${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(stringToSign)
.digest("hex");
const isValid = expectedSignature === req.headers["x-twenty-webhook-signature"];
```
## Webhooks vs flujos de trabajo
| Método | Dirección | Caso de uso |
| --------------------------------------------- | --------- | --------------------------------------------------------------------------------- |
| **Webhooks** | SALIDA | Notificar automáticamente a los sistemas externos cualquier cambio en un registro |
| **Flujo de trabajo + solicitud HTTP** | SALIDA | Enviar datos con lógica personalizada (filtros, transformaciones) |
| **Disparador de webhook de flujo de trabajo** | ENTRADA | Recibir datos en Twenty desde sistemas externos |
Para recibir datos externos, consulta [Configurar un disparador de webhook](/l/es/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger).

View File

@@ -0,0 +1,32 @@
---
title: Ampliar
description: Amplía la funcionalidad de Twenty con APIs, webhooks y aplicaciones personalizadas.
---
<Frame>
<img src="/images/user-guide/integrations/plug.png" alt="IA" />
</Frame>
## Resumen
Twenty está diseñado para ser extensible. Usa nuestras APIs, webhooks y el framework de aplicaciones para integrarte con tus herramientas existentes y crear funcionalidades personalizadas.
## Lo que puedes hacer
* **APIs**: Consulta y modifica tus datos de CRM de forma programática usando REST o GraphQL
* **Webhooks**: Recibe notificaciones en tiempo real cuando ocurran eventos en Twenty
* **Aplicaciones**: Crea aplicaciones personalizadas que amplíen las capacidades de Twenty
## Primeros pasos
<CardGroup cols={2}>
<Card title="APIs" icon="code" href="/l/es/developers/extend/api">
Conéctate a Twenty de forma programática
</Card>
<Card title="Webhooks" icon="bell" href="/l/es/developers/extend/webhooks">
Recibe notificaciones de eventos en tiempo real
</Card>
<Card title="Aplicaciones" icon="puzzle-piece" href="/l/es/developers/extend/apps/getting-started/quick-start">
Crea personalizaciones como código
</Card>
</CardGroup>

View File

@@ -0,0 +1,189 @@
---
title: OAuth
icon: key
description: Flujo de código de autorización con PKCE y credenciales de cliente para acceso de servidor a servidor.
---
Twenty implementa OAuth 2.0 con código de autorización + PKCE para aplicaciones orientadas al usuario y credenciales de cliente para acceso de servidor a servidor. Los clientes se registran dinámicamente mediante [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) — sin configuración manual en un panel.
## Cuándo usar OAuth
| Escenario | Método de autenticación |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Scripts internos, automatización | [Clave de API](/l/es/developers/extend/api#authentication) |
| Aplicación externa que actúa en nombre de un usuario | **OAuth — Código de autorización** |
| De servidor a servidor, sin contexto de usuario | **OAuth — Credenciales de cliente** |
| Aplicación de Twenty con extensiones de UI | [Aplicaciones](/l/es/developers/extend/apps/getting-started/quick-start) (OAuth se gestiona automáticamente) |
## Registrar un cliente
Twenty admite el **registro dinámico de clientes** según [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591). No se necesita configuración manual — regístrelo de forma programática:
```bash
POST /oauth/register
Content-Type: application/json
{
"client_name": "My Integration",
"redirect_uris": ["https://myapp.com/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_post"
}
```
**Respuesta:**
```json
{
"client_id": "abc123",
"client_secret": "secret456",
"client_name": "My Integration",
"redirect_uris": ["https://myapp.com/callback"]
}
```
<Warning>
Almacene el `client_secret` de forma segura — no se puede recuperar más tarde.
</Warning>
## Ámbitos
| Ámbito | Acceso |
| --------- | ----------------------------------------------------------------- |
| `api` | Acceso completo de lectura/escritura a las API de Core y Metadata |
| `profile` | Leer la información de perfil del usuario autenticado |
Solicite los ámbitos como una cadena separada por espacios: `scope=api profile`
## Flujo de código de autorización
Use este flujo cuando su aplicación actúe en nombre de un usuario de Twenty.
### 1. Redirija al usuario para autorizar
```
GET /oauth/authorize?
client_id=YOUR_CLIENT_ID&
response_type=code&
redirect_uri=https://myapp.com/callback&
scope=api&
state=random_state_value&
code_challenge=CHALLENGE&
code_challenge_method=S256
```
| Parámetro | Obligatorio | Descripción |
| ----------------------- | ----------- | -------------------------------------------------------------------- |
| `client_id` | Sí | Su ID de cliente registrado |
| `response_type` | Sí | Debe ser `code` |
| `redirect_uri` | Sí | Debe coincidir con un URI de redirección registrado |
| `scope` | No | Ámbitos separados por espacios (valor predeterminado: `api`) |
| `state` | Recomendado | Cadena aleatoria para evitar ataques CSRF |
| `code_challenge` | Recomendado | Desafío PKCE (hash SHA-256 del verificador, codificado en base64url) |
| `code_challenge_method` | Recomendado | Debe ser `S256` al usar PKCE |
El usuario ve una pantalla de consentimiento y aprueba o deniega el acceso.
### 2. Gestione el callback
Tras la autorización, Twenty redirige de vuelta a su `redirect_uri`:
```
https://myapp.com/callback?code=AUTH_CODE&state=random_state_value
```
Verifique que `state` coincida con lo que envió.
### 3. Intercambie el código por tokens
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
code_verifier=YOUR_PKCE_VERIFIER
```
**Respuesta:**
```json
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBh..."
}
```
### 4. Use el token de acceso
```bash
GET /rest/companies
Authorization: Bearer ACCESS_TOKEN
```
### 5. Actualice cuando caduque
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=YOUR_REFRESH_TOKEN&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
```
## Flujo de credenciales de cliente
Para integraciones de servidor a servidor sin interacción del usuario:
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
scope=api
```
El token devuelto tiene acceso a nivel de espacio de trabajo, no está vinculado a ningún usuario específico.
## Descubrimiento del servidor
Twenty publica su configuración de OAuth en un endpoint de descubrimiento estándar:
```
GET /.well-known/oauth-authorization-server
```
Esto devuelve todos los endpoints, los tipos de concesión compatibles, los ámbitos y las capacidades — útil para crear clientes OAuth genéricos.
## Resumen de Puntos de Acceso de API
| Endpoint | Propósito |
| ----------------------------------------- | ---------------------------------------- |
| `/.well-known/oauth-authorization-server` | Descubrimiento de metadatos del servidor |
| `/oauth/register` | Registro dinámico de clientes |
| `/oauth/authorize` | Autorización del usuario |
| `/oauth/token` | Intercambio y renovación de tokens |
| Entorno | URL base |
| ------------------- | ------------------------ |
| **Nube** | `https://api.twenty.com` |
| **Autoalojamiento** | `https://{your-domain}` |
## OAuth frente a claves de API
| | Claves API | OAuth |
| ------------------------ | --------------------------------------- | ------------------------------------------------- |
| **Configuración** | Generar en Ajustes | Registrar un cliente, implementar el flujo |
| **Contexto del usuario** | Ninguno (a nivel de espacio de trabajo) | Permisos de un usuario específico |
| **Ideal para** | Scripts, herramientas internas | Aplicaciones externas, integraciones multiusuario |
| **Rotación de tokens** | Manual | Automática mediante tokens de actualización |
| **Acceso con ámbitos** | Acceso completo a la API | Granular mediante ámbitos |

View File

@@ -0,0 +1,117 @@
---
title: Webhooks
icon: satellite-dish
description: Recibe notificaciones cuando los registros cambien — HTTP POST a tu endpoint en cada creación, actualización o eliminación.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Twenty envía un HTTP POST a tu URL cada vez que se crea, actualiza o elimina un registro. Todos los tipos de objetos están cubiertos, incluidos los objetos personalizados.
## Crear un webhook
1. Ve a **Configuración → APIs y Webhooks → Webhooks**
2. Haz clic en **+ Crear webhook**
3. Introduce la URL de tu webhook (debe ser públicamente accesible)
4. Haz clic en **Guardar**
El webhook se activa de inmediato y comienza a enviar notificaciones.
<VimeoEmbed videoId="928786708" title="Crear un webhook" />
### Gestionar webhooks
**Editar**: Haz clic en el webhook → Actualizar la URL → **Guardar**
**Eliminar**: Haz clic en el webhook → **Eliminar** → Confirmar
## Eventos
Twenty envía webhooks para estos tipos de eventos:
| Evento | Ejemplo |
| ---------------------------- | ---------------------------------------------------------- |
| **Se crea un registro** | `person.created`, `company.created`, `note.created` |
| **Se actualiza un registro** | `person.updated`, `company.updated`, `opportunity.updated` |
| **Se elimina un registro** | `person.deleted`, `company.deleted` |
Todos los tipos de eventos se envían a la URL de tu webhook. Es posible que se agregue el filtrado de eventos en versiones futuras.
## Formato de la carga útil
Cada webhook envía una solicitud HTTP POST con un cuerpo JSON:
```json
{
"event": "person.created",
"data": {
"id": "abc12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"createdAt": "2025-02-10T15:30:45Z",
"createdBy": "user_123"
},
"timestamp": "2025-02-10T15:30:50Z"
}
```
| Campo | Descripción |
| ----------- | -------------------------------------------------- |
| `event` | Qué ocurrió (p. ej., `person.created`) |
| `data` | El registro completo que se creó/actualizó/eliminó |
| `timestamp` | Cuándo ocurrió el evento (UTC) |
<Note>
Responde con un **estado HTTP 2xx** (200-299) para confirmar la recepción. Las respuestas que no sean 2xx se registran como errores de entrega.
</Note>
## Validación de webhook
Twenty firma cada solicitud de webhook por seguridad. Valida las firmas para garantizar que las solicitudes sean auténticas.
### Encabezados
| Encabezado | Descripción |
| ---------------------------- | ------------------------------- |
| `X-Twenty-Webhook-Signature` | Firma HMAC SHA256 |
| `X-Twenty-Webhook-Timestamp` | Marca de tiempo de la solicitud |
### Pasos de validación
1. Obtén la marca de tiempo de `X-Twenty-Webhook-Timestamp`
2. Crea la cadena: `{timestamp}:{JSON payload}`
3. Calcula HMAC SHA256 usando tu secreto de webhook
4. Compara con `X-Twenty-Webhook-Signature`
### Ejemplo (Node.js)
```javascript
const crypto = require("crypto");
const timestamp = req.headers["x-twenty-webhook-timestamp"];
const payload = JSON.stringify(req.body);
const secret = "your-webhook-secret";
const stringToSign = `${timestamp}:${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(stringToSign)
.digest("hex");
const receivedSignature = req.headers["x-twenty-webhook-signature"];
const isValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(receivedSignature, "hex")
);
```
## Webhooks vs flujos de trabajo
| Método | Dirección | Caso de uso |
| --------------------------------------------- | --------- | --------------------------------------------------------------------------------- |
| **Webhooks** | SALIDA | Notificar automáticamente a los sistemas externos cualquier cambio en un registro |
| **Flujo de trabajo + solicitud HTTP** | SALIDA | Enviar datos con lógica personalizada (filtros, transformaciones) |
| **Disparador de webhook de flujo de trabajo** | ENTRADA | Recibir datos en Twenty desde sistemas externos |
Para recibir datos externos, consulta [Configurar un disparador de webhook](/l/es/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger).