mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
2062 lines
81 KiB
Text
2062 lines
81 KiB
Text
---
|
||
title: Building Apps
|
||
description: Define objects, logic functions, front components, and more with the Twenty SDK.
|
||
---
|
||
|
||
<Warning>
|
||
Apps are currently in alpha. The feature works but is still evolving.
|
||
</Warning>
|
||
|
||
The `twenty-sdk` package provides typed building blocks to create your app. This page covers every entity type and API client available in the SDK.
|
||
|
||
## DefineEntity functions
|
||
|
||
The SDK provides functions to define your app entities. You must use `export default defineEntity({...})` for the SDK to detect your entities. These functions validate your configuration at build time and provide IDE autocompletion and type safety.
|
||
|
||
<Note>
|
||
**File organization is up to you.**
|
||
Entity detection is AST-based — the SDK finds `export default defineEntity(...)` calls regardless of where the file lives. Grouping files by type (e.g., `logic-functions/`, `roles/`) is just a convention, not a requirement.
|
||
</Note>
|
||
|
||
<AccordionGroup>
|
||
<Accordion title="defineRole" description="Configure role permissions and object access">
|
||
|
||
Roles encapsulate permissions on your workspace's objects and actions.
|
||
|
||
```ts restricted-company-role.ts
|
||
import {
|
||
defineRole,
|
||
PermissionFlag,
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||
} from 'twenty-sdk';
|
||
|
||
export default defineRole({
|
||
universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6',
|
||
label: 'My new role',
|
||
description: 'A role that can be used in your workspace',
|
||
canReadAllObjectRecords: false,
|
||
canUpdateAllObjectRecords: false,
|
||
canSoftDeleteAllObjectRecords: false,
|
||
canDestroyAllObjectRecords: false,
|
||
canUpdateAllSettings: false,
|
||
canBeAssignedToAgents: false,
|
||
canBeAssignedToUsers: false,
|
||
canBeAssignedToApiKeys: false,
|
||
objectPermissions: [
|
||
{
|
||
objectUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||
canReadObjectRecords: true,
|
||
canUpdateObjectRecords: true,
|
||
canSoftDeleteObjectRecords: false,
|
||
canDestroyObjectRecords: false,
|
||
},
|
||
],
|
||
fieldPermissions: [
|
||
{
|
||
objectUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||
fieldUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
|
||
canReadFieldValue: false,
|
||
canUpdateFieldValue: false,
|
||
},
|
||
],
|
||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||
});
|
||
```
|
||
|
||
</Accordion>
|
||
<Accordion title="defineApplication" description="Configure application metadata (required, one per app)">
|
||
|
||
Every app must have exactly one `defineApplication` call that describes:
|
||
|
||
- **Identity**: identifiers, display name, and description.
|
||
- **Permissions**: which role its functions and front components use.
|
||
- **(Optional) Variables**: key–value pairs exposed to your functions as environment variables.
|
||
- **(Optional) Pre-install / post-install functions**: logic functions that run before or after installation.
|
||
|
||
```ts src/application-config.ts
|
||
import { defineApplication } from 'twenty-sdk';
|
||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||
|
||
export default defineApplication({
|
||
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
|
||
displayName: 'My Twenty App',
|
||
description: 'My first Twenty app',
|
||
icon: 'IconWorld',
|
||
applicationVariables: {
|
||
DEFAULT_RECIPIENT_NAME: {
|
||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||
description: 'Default recipient name for postcards',
|
||
value: 'Jane Doe',
|
||
isSecret: false,
|
||
},
|
||
},
|
||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||
});
|
||
```
|
||
|
||
Notes:
|
||
- `universalIdentifier` fields are deterministic IDs you own. Generate them once and keep them stable across syncs.
|
||
- `applicationVariables` become environment variables for your functions and front components (e.g., `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
|
||
- `defaultRoleUniversalIdentifier` must reference a role defined with `defineRole()` (see above).
|
||
- Pre-install and post-install functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`.
|
||
|
||
#### Marketplace metadata
|
||
|
||
If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how it appears in the marketplace:
|
||
|
||
| Field | Description |
|
||
|-------|-------------|
|
||
| `author` | Author or company name |
|
||
| `category` | App category for marketplace filtering |
|
||
| `logoUrl` | Path to your app logo (e.g., `public/logo.png`) |
|
||
| `screenshots` | Array of screenshot paths (e.g., `public/screenshot-1.png`) |
|
||
| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm |
|
||
| `websiteUrl` | Link to your website |
|
||
| `termsUrl` | Link to terms of service |
|
||
| `emailSupport` | Support email address |
|
||
| `issueReportUrl` | Link to issue tracker |
|
||
|
||
#### Roles and permissions
|
||
|
||
The `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions and front components. See `defineRole` above for details.
|
||
|
||
- The runtime token injected as `TWENTY_APP_ACCESS_TOKEN` is derived from this role.
|
||
- The typed client is restricted to the permissions granted to that role.
|
||
- Follow least-privilege: create a dedicated role with only the permissions your functions need.
|
||
|
||
##### Default function role
|
||
|
||
When you scaffold a new app, the CLI creates a default role file:
|
||
|
||
```ts src/roles/default-role.ts
|
||
import { defineRole, PermissionFlag } from 'twenty-sdk';
|
||
|
||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||
'b648f87b-1d26-4961-b974-0908fd991061';
|
||
|
||
export default defineRole({
|
||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||
label: 'Default function role',
|
||
description: 'Default role for function Twenty client',
|
||
canReadAllObjectRecords: true,
|
||
canUpdateAllObjectRecords: false,
|
||
canSoftDeleteAllObjectRecords: false,
|
||
canDestroyAllObjectRecords: false,
|
||
canUpdateAllSettings: false,
|
||
canBeAssignedToAgents: false,
|
||
canBeAssignedToUsers: false,
|
||
canBeAssignedToApiKeys: false,
|
||
objectPermissions: [],
|
||
fieldPermissions: [],
|
||
permissionFlags: [],
|
||
});
|
||
```
|
||
|
||
This role's `universalIdentifier` is referenced in `application-config.ts` as `defaultRoleUniversalIdentifier`:
|
||
|
||
- **\*.role.ts** defines what the role can do.
|
||
- **application-config.ts** points to that role so your functions inherit its permissions.
|
||
|
||
Notes:
|
||
- Start from the scaffolded role, then progressively restrict it following least-privilege.
|
||
- Replace `objectPermissions` and `fieldPermissions` with the objects and fields your functions actually need.
|
||
- `permissionFlags` control access to platform-level capabilities. Keep them minimal.
|
||
- See a working example: [`hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
|
||
|
||
</Accordion>
|
||
<Accordion title="defineObject" description="Define custom objects with fields">
|
||
|
||
Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation:
|
||
|
||
```ts postCard.object.ts
|
||
import { defineObject, FieldType } from 'twenty-sdk';
|
||
|
||
enum PostCardStatus {
|
||
DRAFT = 'DRAFT',
|
||
SENT = 'SENT',
|
||
DELIVERED = 'DELIVERED',
|
||
RETURNED = 'RETURNED',
|
||
}
|
||
|
||
export default defineObject({
|
||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||
nameSingular: 'postCard',
|
||
namePlural: 'postCards',
|
||
labelSingular: 'Post Card',
|
||
labelPlural: 'Post Cards',
|
||
description: 'A post card object',
|
||
icon: 'IconMail',
|
||
fields: [
|
||
{
|
||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||
name: 'content',
|
||
type: FieldType.TEXT,
|
||
label: 'Content',
|
||
description: "Postcard's content",
|
||
icon: 'IconAbc',
|
||
},
|
||
{
|
||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||
name: 'recipientName',
|
||
type: FieldType.FULL_NAME,
|
||
label: 'Recipient name',
|
||
icon: 'IconUser',
|
||
},
|
||
{
|
||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||
name: 'recipientAddress',
|
||
type: FieldType.ADDRESS,
|
||
label: 'Recipient address',
|
||
icon: 'IconHome',
|
||
},
|
||
{
|
||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||
name: 'status',
|
||
type: FieldType.SELECT,
|
||
label: 'Status',
|
||
icon: 'IconSend',
|
||
defaultValue: `'${PostCardStatus.DRAFT}'`,
|
||
options: [
|
||
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
|
||
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
|
||
{ value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
|
||
{ value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
|
||
],
|
||
},
|
||
{
|
||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||
name: 'deliveredAt',
|
||
type: FieldType.DATE_TIME,
|
||
label: 'Delivered at',
|
||
icon: 'IconCheck',
|
||
isNullable: true,
|
||
defaultValue: null,
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
|
||
- Use `defineObject()` for built-in validation and better IDE support.
|
||
- The `universalIdentifier` must be unique and stable across deployments.
|
||
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
|
||
- The `fields` array is optional — you can define objects without custom fields.
|
||
- You can scaffold new objects using `yarn twenty add`, which guides you through naming, fields, and relationships.
|
||
|
||
<Note>
|
||
**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields
|
||
such as `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` and `deletedAt`.
|
||
You don't need to define these in your `fields` array — only add your custom fields.
|
||
You can override default fields by defining a field with the same name in your `fields` array,
|
||
but this is not recommended.
|
||
</Note>
|
||
|
||
</Accordion>
|
||
<Accordion title="defineField — Standard fields" description="Extend existing objects with additional fields">
|
||
|
||
Use `defineField()` to add fields to objects you don't own — such as standard Twenty objects (Person, Company, etc.) or objects from other apps. Unlike inline fields in `defineObject()`, standalone fields require an `objectUniversalIdentifier` to specify which object they extend:
|
||
|
||
```ts src/fields/company-loyalty-tier.field.ts
|
||
import { defineField, FieldType } from 'twenty-sdk';
|
||
|
||
export default defineField({
|
||
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
|
||
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
|
||
name: 'loyaltyTier',
|
||
type: FieldType.SELECT,
|
||
label: 'Loyalty Tier',
|
||
icon: 'IconStar',
|
||
options: [
|
||
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
|
||
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
|
||
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
|
||
],
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- `objectUniversalIdentifier` identifies the target object. For standard objects, use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` exported from `twenty-sdk`.
|
||
- When defining fields inline in `defineObject()`, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
|
||
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineField — Relation fields" description="Connect objects together with bidirectional relations">
|
||
|
||
Relations connect objects together. In Twenty, relations are always **bidirectional** — you define both sides, and each side references the other.
|
||
|
||
There are two relation types:
|
||
|
||
| Relation type | Description | Has foreign key? |
|
||
|---------------|-------------|-----------------|
|
||
| `MANY_TO_ONE` | Many records of this object point to one record of the target | Yes (`joinColumnName`) |
|
||
| `ONE_TO_MANY` | One record of this object has many records of the target | No (inverse side) |
|
||
|
||
#### How relations work
|
||
|
||
Every relation requires **two fields** that reference each other:
|
||
|
||
1. The **MANY_TO_ONE** side — lives on the object that holds the foreign key
|
||
2. The **ONE_TO_MANY** side — lives on the object that owns the collection
|
||
|
||
Both fields use `FieldType.RELATION` and cross-reference each other via `relationTargetFieldMetadataUniversalIdentifier`.
|
||
|
||
#### Example: Post Card has many Recipients
|
||
|
||
Suppose a `PostCard` can be sent to many `PostCardRecipient` records. Each recipient belongs to exactly one post card.
|
||
|
||
**Step 1: Define the ONE_TO_MANY side on PostCard** (the "one" side):
|
||
|
||
```ts src/fields/post-card-recipients-on-post-card.field.ts
|
||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||
|
||
// Export so the other side can reference it
|
||
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
|
||
// Import from the other side
|
||
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
|
||
|
||
export default defineField({
|
||
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||
type: FieldType.RELATION,
|
||
name: 'postCardRecipients',
|
||
label: 'Post Card Recipients',
|
||
icon: 'IconUsers',
|
||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
|
||
universalSettings: {
|
||
relationType: RelationType.ONE_TO_MANY,
|
||
},
|
||
});
|
||
```
|
||
|
||
**Step 2: Define the MANY_TO_ONE side on PostCardRecipient** (the "many" side — holds the foreign key):
|
||
|
||
```ts src/fields/post-card-on-post-card-recipient.field.ts
|
||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
|
||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||
|
||
// Export so the other side can reference it
|
||
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
|
||
// Import from the other side
|
||
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
|
||
|
||
export default defineField({
|
||
universalIdentifier: POST_CARD_FIELD_ID,
|
||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||
type: FieldType.RELATION,
|
||
name: 'postCard',
|
||
label: 'Post Card',
|
||
icon: 'IconMail',
|
||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||
universalSettings: {
|
||
relationType: RelationType.MANY_TO_ONE,
|
||
onDelete: OnDeleteAction.CASCADE,
|
||
joinColumnName: 'postCardId',
|
||
},
|
||
});
|
||
```
|
||
|
||
<Note>
|
||
**Circular imports:** Both relation fields reference each other's `universalIdentifier`. To avoid circular import issues, export your field IDs as named constants from each file, and import them in the other file. The build system resolves these at compile time.
|
||
</Note>
|
||
|
||
#### Relating to standard objects
|
||
|
||
To create a relation with a built-in Twenty object (Person, Company, etc.), use `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';
|
||
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',
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Relation field properties
|
||
|
||
| Property | Required | Description |
|
||
|----------|----------|-------------|
|
||
| `type` | Yes | Must be `FieldType.RELATION` |
|
||
| `relationTargetObjectMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the target object |
|
||
| `relationTargetFieldMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the matching field on the target object |
|
||
| `universalSettings.relationType` | Yes | `RelationType.MANY_TO_ONE` or `RelationType.ONE_TO_MANY` |
|
||
| `universalSettings.onDelete` | MANY_TO_ONE only | What happens when the referenced record is deleted: `CASCADE`, `SET_NULL`, `RESTRICT`, or `NO_ACTION` |
|
||
| `universalSettings.joinColumnName` | MANY_TO_ONE only | Database column name for the foreign key (e.g., `postCardId`) |
|
||
|
||
#### Inline relation fields in defineObject
|
||
|
||
You can also define relation fields directly inside `defineObject()`. In that case, omit `objectUniversalIdentifier` — it's inherited from the parent object:
|
||
|
||
```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
|
||
],
|
||
});
|
||
```
|
||
</Accordion>
|
||
<Accordion title="defineLogicFunction" description="Define logic functions and their triggers">
|
||
|
||
Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers.
|
||
|
||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||
import { defineLogicFunction } from 'twenty-sdk';
|
||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||
|
||
const handler = async (params: RoutePayload) => {
|
||
const client = new CoreApiClient();
|
||
const name = 'name' in params.queryStringParameters
|
||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||
: 'Hello world';
|
||
|
||
const result = await client.mutation({
|
||
createPostCard: {
|
||
__args: { data: { name } },
|
||
id: true,
|
||
name: true,
|
||
},
|
||
});
|
||
return result;
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||
name: 'create-new-post-card',
|
||
timeoutSeconds: 2,
|
||
handler,
|
||
httpRouteTriggerSettings: {
|
||
path: '/post-card/create',
|
||
httpMethod: 'GET',
|
||
isAuthRequired: true,
|
||
},
|
||
/*databaseEventTriggerSettings: {
|
||
eventName: 'people.created',
|
||
},*/
|
||
/*cronTriggerSettings: {
|
||
pattern: '0 0 1 1 *',
|
||
},*/
|
||
});
|
||
```
|
||
|
||
Available trigger types:
|
||
- **httpRoute**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**:
|
||
> e.g. `path: '/post-card/create'` is callable at `https://your-twenty-server.com/s/post-card/create`
|
||
- **cron**: Runs your function on a schedule using a CRON expression.
|
||
- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function.
|
||
> e.g. `person.updated`, `*.created`, `company.*`
|
||
|
||
<Note>
|
||
You can also manually execute a function using the CLI:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec -n create-new-post-card -p '{"key": "value"}'
|
||
```
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||
```
|
||
|
||
You can watch logs with:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty logs
|
||
```
|
||
</Note>
|
||
|
||
#### Route trigger payload
|
||
|
||
When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the
|
||
[AWS HTTP API v2 format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
|
||
Import the `RoutePayload` type from `twenty-sdk`:
|
||
|
||
```ts
|
||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
|
||
|
||
const handler = async (event: RoutePayload) => {
|
||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||
const { method, path } = event.requestContext.http;
|
||
|
||
return { message: 'Success' };
|
||
};
|
||
```
|
||
|
||
The `RoutePayload` type has the following structure:
|
||
|
||
| Property | Type | Description | Example |
|
||
|----------|------|-------------|---------|
|
||
| `headers` | `Record<string, string \| undefined>` | HTTP headers (only those listed in `forwardedRequestHeaders`) | see section below |
|
||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }`|
|
||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||
| `body` | `object \| null` | Parsed request body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded | |
|
||
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) | |
|
||
| `requestContext.http.path` | `string` | Raw request path | |
|
||
|
||
|
||
#### forwardedRequestHeaders
|
||
|
||
By default, HTTP headers from incoming requests are **not** passed to your logic function for security reasons.
|
||
To access specific headers, list them in the `forwardedRequestHeaders` array:
|
||
|
||
```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'],
|
||
},
|
||
});
|
||
```
|
||
|
||
In your handler, access the forwarded headers like this:
|
||
|
||
```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>
|
||
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
|
||
</Note>
|
||
|
||
#### Exposing a function as a tool
|
||
|
||
Logic functions can be exposed as **tools** for AI agents and workflows. When marked as a tool, a function becomes discoverable by Twenty's AI features and can be used in workflow automations.
|
||
|
||
To mark a logic function as a tool, set `isTool: true`:
|
||
|
||
```ts src/logic-functions/enrich-company.logic-function.ts
|
||
import { defineLogicFunction } from 'twenty-sdk';
|
||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||
|
||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||
const client = new CoreApiClient();
|
||
|
||
const result = await client.mutation({
|
||
createTask: {
|
||
__args: {
|
||
data: {
|
||
title: `Enrich data for ${params.companyName}`,
|
||
body: `Domain: ${params.domain ?? 'unknown'}`,
|
||
},
|
||
},
|
||
id: true,
|
||
},
|
||
});
|
||
|
||
return { taskId: result.createTask.id };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||
name: 'enrich-company',
|
||
description: 'Enrich a company record with external data',
|
||
timeoutSeconds: 10,
|
||
handler,
|
||
isTool: true,
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
|
||
- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events at the same time.
|
||
- **`toolInputSchema`** (optional): A JSON Schema object describing the parameters your function accepts. The schema is computed automatically from source code static analysis, but you can set it explicitly:
|
||
|
||
```ts
|
||
export default defineLogicFunction({
|
||
...,
|
||
toolInputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
companyName: {
|
||
type: 'string',
|
||
description: 'The name of the company to enrich',
|
||
},
|
||
domain: {
|
||
type: 'string',
|
||
description: 'The company website domain (optional)',
|
||
},
|
||
},
|
||
required: ['companyName'],
|
||
},
|
||
});
|
||
```
|
||
|
||
<Note>
|
||
**Write a good `description`.** AI agents rely on the function's `description` field to decide when to use the tool. Be specific about what the tool does and when it should be called.
|
||
</Note>
|
||
|
||
</Accordion>
|
||
<Accordion title="definePostInstallLogicFunction" description="Define a post-install logic function (one per app)">
|
||
|
||
A post-install function is a logic function that runs automatically once your app has finished installing on a workspace. The server executes it **after** the app's metadata has been synchronized and the SDK client has been generated, so the workspace is fully ready to use and the new schema is in place. Typical use cases include seeding default data, creating initial records, configuring workspace settings, or provisioning resources on third-party services.
|
||
|
||
```ts src/logic-functions/post-install.ts
|
||
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk';
|
||
|
||
const handler = async (payload: InstallPayload): Promise<void> => {
|
||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||
};
|
||
|
||
export default definePostInstallLogicFunction({
|
||
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
|
||
name: 'post-install',
|
||
description: 'Runs after installation to set up the application.',
|
||
timeoutSeconds: 300,
|
||
shouldRunOnVersionUpgrade: false,
|
||
shouldRunSynchronously: false,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
You can also manually execute the post-install function at any time using the CLI:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec --postInstall
|
||
```
|
||
|
||
Key points:
|
||
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||
- The handler receives an `InstallPayload` with `{ previousVersion?: string; newVersion: string }` — `newVersion` is the version being installed, and `previousVersion` is the version that was previously installed (or `undefined` on a fresh install). Use these values to distinguish fresh installs from upgrades and to run version-specific migration logic.
|
||
- **When the hook runs**: on fresh installs only, by default. Pass `shouldRunOnVersionUpgrade: true` if you also want it to run when the app is upgraded from a previous version. When omitted, the flag defaults to `false` and upgrades skip the hook.
|
||
- **Execution model — async by default, sync opt-in**: the `shouldRunSynchronously` flag controls *how* post-install is executed.
|
||
- `shouldRunSynchronously: false` *(default)* — the hook is **enqueued on the message queue** with `retryLimit: 3` and runs asynchronously in a worker. The install response returns as soon as the job is enqueued, so a slow or failing handler does not block the caller. The worker will retry up to three times. **Use this for long-running jobs** — seeding large datasets, calling slow third-party APIs, provisioning external resources, anything that might exceed a reasonable HTTP response window.
|
||
- `shouldRunSynchronously: true` — the hook is executed **inline during the install flow** (same executor as pre-install). The install request blocks until the handler finishes, and if it throws, the install caller receives a `POST_INSTALL_ERROR`. No automatic retries. **Use this for fast, must-complete-before-response work** — for example, emitting a validation error to the user, or quick setup that the client will rely on immediately after the install call returns. Keep in mind the metadata migration has already been applied by the time post-install runs, so a sync-mode failure does **not** roll back the schema changes — it only surfaces the error.
|
||
- Make sure your handler is idempotent. In async mode the queue may retry up to three times; in either mode the hook may run again on upgrades when `shouldRunOnVersionUpgrade: true`.
|
||
- The environment variables `APPLICATION_ID`, `APP_ACCESS_TOKEN`, and `API_URL` are available inside the handler (same as any other logic function), so you can call the Twenty API with an application access token scoped to your app.
|
||
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
|
||
- The function's `universalIdentifier`, `shouldRunOnVersionUpgrade`, and `shouldRunSynchronously` are automatically attached to the application manifest under the `postInstallLogicFunction` field during the build — you do not need to reference them in `defineApplication()`.
|
||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||
- **Not executed in dev mode**: when an app is registered locally (via `yarn twenty dev`), the server skips the install flow entirely and syncs files directly through the CLI watcher — so post-install never runs in dev mode, regardless of `shouldRunSynchronously`. Use `yarn twenty exec --postInstall` to trigger it manually against a running workspace.
|
||
|
||
</Accordion>
|
||
<Accordion title="definePreInstallLogicFunction" description="Define a pre-install logic function (one per app)">
|
||
|
||
A pre-install function is a logic function that runs automatically during installation, **before the workspace metadata migration is applied**. It shares the same payload shape as post-install (`InstallPayload`), but it is positioned earlier in the install flow so it can prepare state that the upcoming migration depends on — typical uses include backing up data, validating compatibility with the new schema, or archiving records that are about to be restructured or dropped.
|
||
|
||
```ts src/logic-functions/pre-install.ts
|
||
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk';
|
||
|
||
const handler = async (payload: InstallPayload): Promise<void> => {
|
||
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
||
};
|
||
|
||
export default definePreInstallLogicFunction({
|
||
universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
|
||
name: 'pre-install',
|
||
description: 'Runs before installation to prepare the application.',
|
||
timeoutSeconds: 300,
|
||
shouldRunOnVersionUpgrade: true,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
You can also manually execute the pre-install function at any time using the CLI:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec --preInstall
|
||
```
|
||
|
||
Key points:
|
||
- Pre-install functions use `definePreInstallLogicFunction()` — same specialized config as post-install, just attached to a different lifecycle slot.
|
||
- Both pre- and post-install handlers receive the same `InstallPayload` type: `{ previousVersion?: string; newVersion: string }`. Import it once and reuse it for both hooks.
|
||
- **When the hook runs**: positioned just before the workspace metadata migration (`synchronizeFromManifest`). Before executing, the server runs a purely additive "pared-down sync" that registers the **new** version's pre-install function in the workspace metadata — nothing else is touched — and then executes it. Because this sync is additive-only, the previous version's objects, fields, and data are still intact when your handler runs: you can safely read and back up pre-migration state.
|
||
- **Execution model**: pre-install is executed **synchronously** and **blocks the install**. If the handler throws, the install is aborted before any schema changes are applied — the workspace stays on the previous version in a consistent state. This is intentional: pre-install is your last chance to refuse a risky upgrade.
|
||
- As with post-install, only one pre-install function is allowed per application. It is attached to the application manifest under `preInstallLogicFunction` automatically during the build.
|
||
- **Not executed in dev mode**: same as post-install — the install flow is skipped entirely for locally-registered apps, so pre-install never runs under `yarn twenty dev`. Use `yarn twenty exec --preInstall` to trigger it manually.
|
||
|
||
</Accordion>
|
||
<Accordion title="Pre-install vs post-install: when to use which" description="Choosing the right install hook">
|
||
|
||
Both hooks are part of the same install flow and receive the same `InstallPayload`. The difference is **when** they run relative to the workspace metadata migration, and that changes what data they can safely touch.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ install flow │
|
||
│ │
|
||
│ upload package → [pre-install] → metadata migration → │
|
||
│ generate SDK → [post-install] │
|
||
│ │
|
||
│ old schema visible new schema visible │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Pre-install is always **synchronous** (it blocks the install and can abort it). Post-install is **asynchronous by default** — enqueued on a worker with automatic retries — but can opt into synchronous execution with `shouldRunSynchronously: true`. See the `definePostInstallLogicFunction` accordion above for when to use each mode.
|
||
|
||
**Use `post-install` for anything that needs the new schema to exist.** This is the common case:
|
||
|
||
- Seeding default data (creating initial records, default views, demo content) against newly-added objects and fields.
|
||
- Registering webhooks with third-party services now that the app has its credentials.
|
||
- Calling your own API to finish setup that depends on the synchronized metadata.
|
||
- Idempotent "ensure this exists" logic that should reconcile state on every upgrade — combine with `shouldRunOnVersionUpgrade: true`.
|
||
|
||
Example — seed a default `PostCard` record after install:
|
||
|
||
```ts src/logic-functions/post-install.ts
|
||
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk';
|
||
import { createClient } from './generated/client';
|
||
|
||
const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
|
||
if (previousVersion) return; // fresh installs only
|
||
|
||
const client = createClient();
|
||
await client.postCard.create({
|
||
data: { title: 'Welcome to Postcard', content: 'Your first card!' },
|
||
});
|
||
};
|
||
|
||
export default definePostInstallLogicFunction({
|
||
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
|
||
name: 'post-install',
|
||
description: 'Seeds a welcome post card after install.',
|
||
timeoutSeconds: 300,
|
||
shouldRunOnVersionUpgrade: false,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
**Use `pre-install` when a migration would otherwise destroy or corrupt existing data.** Because pre-install runs against the *previous* schema and its failure rolls back the upgrade, it is the right place for anything risky:
|
||
|
||
- **Backing up data that is about to be dropped or restructured** — e.g. you are removing a field in v2 and need to copy its values into another field or export them to storage before the migration runs.
|
||
- **Archiving records that a new constraint would invalidate** — e.g. a field is becoming `NOT NULL` and you need to delete or fix rows with null values first.
|
||
- **Validating compatibility and refusing the upgrade if the current data cannot be migrated cleanly** — throw from the handler and the install aborts with no changes applied. This is safer than discovering the incompatibility mid-migration.
|
||
- **Renaming or rekeying data** ahead of a schema change that would lose the association.
|
||
|
||
Example — archive records before a destructive migration:
|
||
|
||
```ts src/logic-functions/pre-install.ts
|
||
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk';
|
||
import { createClient } from './generated/client';
|
||
|
||
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 = createClient();
|
||
const legacyRecords = await client.postCard.findMany({
|
||
where: { notes: { isNotNull: true } },
|
||
});
|
||
|
||
if (legacyRecords.length === 0) return;
|
||
|
||
// Copy legacy `notes` into the new `description` field before the migration
|
||
// drops the `notes` column. If this fails, the upgrade is aborted and the
|
||
// workspace stays on v1 with all data intact.
|
||
await Promise.all(
|
||
legacyRecords.map((record) =>
|
||
client.postCard.update({
|
||
where: { id: record.id },
|
||
data: { description: record.notes },
|
||
}),
|
||
),
|
||
);
|
||
};
|
||
|
||
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,
|
||
});
|
||
```
|
||
|
||
**Rule of thumb:**
|
||
|
||
| You want to… | Use |
|
||
|---|---|
|
||
| Seed default data, configure the workspace, register external resources | `post-install` |
|
||
| Run long-running seeding or third-party calls that shouldn't block the install response | `post-install` (default — `shouldRunSynchronously: false`, with worker retries) |
|
||
| Run fast setup that the caller will rely on immediately after the install call returns | `post-install` with `shouldRunSynchronously: true` |
|
||
| Read or back up data that the upcoming migration would lose | `pre-install` |
|
||
| Reject an upgrade that would corrupt existing data | `pre-install` (throw from the handler) |
|
||
| Run reconciliation on every upgrade | `post-install` with `shouldRunOnVersionUpgrade: true` |
|
||
| Do one-off setup on the first install only | `post-install` with `shouldRunOnVersionUpgrade: false` (default) |
|
||
|
||
<Note>
|
||
If in doubt, default to **post-install**. Only reach for pre-install when the migration itself is destructive and you need to intercept the previous state before it is gone.
|
||
</Note>
|
||
|
||
</Accordion>
|
||
<Accordion title="defineFrontComponent" description="Define front components for custom UI" >
|
||
|
||
Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code is sandboxed but renders natively in the page, not in an iframe.
|
||
|
||
#### Where front components can be used
|
||
|
||
Front components can render in two locations within Twenty:
|
||
|
||
- **Side panel** — Non-headless front components open in the right-hand side panel. This is the default behavior when a front component is triggered from the command menu.
|
||
- **Widgets (dashboards and record pages)** — Front components can be embedded as widgets inside page layouts. When configuring a dashboard or a record page layout, users can add a front component widget.
|
||
|
||
#### Basic example
|
||
|
||
The quickest way to see a front component in action is to register it as a **command**. Adding a `command` field with `isPinned: true` makes it appear as a quick-action button in the top-right corner of the page — no page layout needed:
|
||
|
||
```tsx src/front-components/hello-world.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
|
||
const HelloWorld = () => {
|
||
return (
|
||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||
<h1>Hello from my app!</h1>
|
||
<p>This component renders inside Twenty.</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||
name: 'hello-world',
|
||
description: 'A simple front component',
|
||
component: HelloWorld,
|
||
command: {
|
||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||
shortLabel: 'Hello',
|
||
label: 'Hello World',
|
||
icon: 'IconBolt',
|
||
isPinned: true,
|
||
availabilityType: 'GLOBAL',
|
||
},
|
||
});
|
||
```
|
||
|
||
After syncing with `yarn twenty dev` (or running a one-shot `yarn twenty dev --once`), the quick action appears in the top-right corner of the page:
|
||
|
||
<div style={{textAlign: 'center'}}>
|
||
<img src="/images/docs/developers/extends/apps/quick-action.png" alt="Quick action button in the top-right corner" />
|
||
</div>
|
||
|
||
Click it to render the component inline.
|
||
|
||
{/* TODO: add screenshot of the rendered front component */}
|
||
|
||
#### Configuration fields
|
||
|
||
| Field | Required | Description |
|
||
|-------|----------|-------------|
|
||
| `universalIdentifier` | Yes | Stable unique ID for this component |
|
||
| `component` | Yes | A React component function |
|
||
| `name` | No | Display name |
|
||
| `description` | No | Description of what the component does |
|
||
| `isHeadless` | No | Set to `true` if the component has no visible UI (see below) |
|
||
| `command` | No | Register the component as a command (see [command options](#command-options) below) |
|
||
|
||
#### Placing a front component on a page
|
||
|
||
Beyond commands, you can embed a front component directly into a record page by adding it as a widget in a **page layout**. See the [definePageLayout](#definepagelayout) section for details.
|
||
|
||
#### Headless vs non-headless
|
||
|
||
Front components come in two rendering modes controlled by the `isHeadless` option:
|
||
|
||
**Non-headless (default)** — The component renders a visible UI. When triggered from the command menu it opens in the side panel. This is the default behavior when `isHeadless` is `false` or omitted.
|
||
|
||
**Headless (`isHeadless: true`)** — The component mounts invisibly in the background. It does not open the side panel. Headless components are designed for actions that execute logic and then unmount themselves — for example, running an async task, navigating to a page, or showing a confirmation modal. They pair naturally with the SDK Command components described below.
|
||
|
||
```tsx src/front-components/sync-tracker.tsx
|
||
import { defineFrontComponent, useRecordId, enqueueSnackbar } from 'twenty-sdk';
|
||
import { useEffect } from 'react';
|
||
|
||
const SyncTracker = () => {
|
||
const recordId = useRecordId();
|
||
|
||
useEffect(() => {
|
||
enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' });
|
||
}, [recordId]);
|
||
|
||
return null;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'sync-tracker',
|
||
description: 'Tracks record views silently',
|
||
isHeadless: true,
|
||
component: SyncTracker,
|
||
});
|
||
```
|
||
|
||
Because the component returns `null`, Twenty skips rendering a container for it — no empty space appears in the layout. The component still has access to all hooks and the host communication API.
|
||
|
||
#### SDK Command components
|
||
|
||
The `twenty-sdk` package provides four Command helper components designed for headless front components. Each component executes an action on mount, handles errors by showing a snackbar notification, and automatically unmounts the front component when done.
|
||
|
||
Import them from `twenty-sdk/command`:
|
||
|
||
- **`Command`** — Runs an async callback via the `execute` prop.
|
||
- **`CommandLink`** — Navigates to an app path. Props: `to`, `params`, `queryParams`, `options`.
|
||
- **`CommandModal`** — Opens a confirmation modal. If the user confirms, executes the `execute` callback. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
|
||
- **`CommandOpenSidePanelPage`** — Opens a specific side panel page. Props: `page`, `pageTitle`, `pageIcon`.
|
||
|
||
Here is a full example of a headless front component using `Command` to run an action from the command menu:
|
||
|
||
```tsx src/front-components/run-action.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
import { Command } from 'twenty-sdk/command';
|
||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||
|
||
const RunAction = () => {
|
||
const execute = async () => {
|
||
const client = new CoreApiClient();
|
||
|
||
await client.mutation({
|
||
createTask: {
|
||
__args: { data: { title: 'Created by my app' } },
|
||
id: true,
|
||
},
|
||
});
|
||
};
|
||
|
||
return <Command execute={execute} />;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
||
name: 'run-action',
|
||
description: 'Creates a task from the command menu',
|
||
component: RunAction,
|
||
isHeadless: true,
|
||
command: {
|
||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||
label: 'Run my action',
|
||
icon: 'IconPlayerPlay',
|
||
},
|
||
});
|
||
```
|
||
|
||
And an example using `CommandModal` to ask for confirmation before executing:
|
||
|
||
```tsx src/front-components/delete-draft.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
import { CommandModal } from 'twenty-sdk/command';
|
||
|
||
const DeleteDraft = () => {
|
||
const execute = async () => {
|
||
// perform the deletion
|
||
};
|
||
|
||
return (
|
||
<CommandModal
|
||
title="Delete draft?"
|
||
subtitle="This action cannot be undone."
|
||
execute={execute}
|
||
confirmButtonText="Delete"
|
||
confirmButtonAccent="danger"
|
||
/>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
|
||
name: 'delete-draft',
|
||
description: 'Deletes a draft with confirmation',
|
||
component: DeleteDraft,
|
||
isHeadless: true,
|
||
command: {
|
||
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
|
||
label: 'Delete draft',
|
||
icon: 'IconTrash',
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Accessing runtime context
|
||
|
||
Inside your component, use SDK hooks to access the current user, record, and component instance:
|
||
|
||
```tsx src/front-components/record-info.tsx
|
||
import {
|
||
defineFrontComponent,
|
||
useUserId,
|
||
useRecordId,
|
||
useFrontComponentId,
|
||
} from 'twenty-sdk';
|
||
|
||
const RecordInfo = () => {
|
||
const userId = useUserId();
|
||
const recordId = useRecordId();
|
||
const componentId = useFrontComponentId();
|
||
|
||
return (
|
||
<div>
|
||
<p>User: {userId}</p>
|
||
<p>Record: {recordId ?? 'No record context'}</p>
|
||
<p>Component: {componentId}</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012',
|
||
name: 'record-info',
|
||
component: RecordInfo,
|
||
});
|
||
```
|
||
|
||
Available hooks:
|
||
|
||
| Hook | Returns | Description |
|
||
|------|---------|-------------|
|
||
| `useUserId()` | `string` or `null` | The current user's ID |
|
||
| `useRecordId()` | `string` or `null` | The current record's ID (when placed on a record page) |
|
||
| `useFrontComponentId()` | `string` | This component instance's ID |
|
||
| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function |
|
||
|
||
#### Host communication API
|
||
|
||
Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`:
|
||
|
||
| Function | Description |
|
||
|----------|-------------|
|
||
| `navigate(to, params?, queryParams?, options?)` | Navigate to a page in the app |
|
||
| `openSidePanelPage(params)` | Open a side panel |
|
||
| `closeSidePanel()` | Close the side panel |
|
||
| `openCommandConfirmationModal(params)` | Show a confirmation dialog |
|
||
| `enqueueSnackbar(params)` | Show a toast notification |
|
||
| `unmountFrontComponent()` | Unmount the component |
|
||
| `updateProgress(progress)` | Update a progress indicator |
|
||
|
||
Here is an example that uses the host API to show a snackbar and close the side panel after an action completes:
|
||
|
||
```tsx src/front-components/archive-record.tsx
|
||
import { defineFrontComponent, useRecordId } from 'twenty-sdk';
|
||
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk';
|
||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||
|
||
const ArchiveRecord = () => {
|
||
const recordId = useRecordId();
|
||
|
||
const handleArchive = async () => {
|
||
const client = new CoreApiClient();
|
||
|
||
await client.mutation({
|
||
updateTask: {
|
||
__args: { id: recordId, data: { status: 'ARCHIVED' } },
|
||
id: true,
|
||
},
|
||
});
|
||
|
||
await enqueueSnackbar({
|
||
message: 'Record archived',
|
||
variant: 'success',
|
||
});
|
||
|
||
await closeSidePanel();
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: '20px' }}>
|
||
<p>Archive this record?</p>
|
||
<button onClick={handleArchive}>Archive</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
|
||
name: 'archive-record',
|
||
description: 'Archives the current record',
|
||
component: ArchiveRecord,
|
||
});
|
||
```
|
||
|
||
#### Command options
|
||
|
||
Adding a `command` field to `defineFrontComponent` registers the component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page.
|
||
|
||
| Field | Required | Description |
|
||
|-------|----------|-------------|
|
||
| `universalIdentifier` | Yes | Stable unique ID for the command |
|
||
| `label` | Yes | Full label shown in the command menu (Cmd+K) |
|
||
| `shortLabel` | No | Shorter label displayed on the pinned quick-action button |
|
||
| `icon` | No | Icon name displayed next to the label (e.g. `'IconBolt'`, `'IconSend'`) |
|
||
| `isPinned` | No | When `true`, shows the command as a quick-action button in the top-right corner of the page |
|
||
| `availabilityType` | No | Controls where the command appears: `'GLOBAL'` (always available), `'RECORD_SELECTION'` (only when records are selected), or `'FALLBACK'` (shown when no other commands match) |
|
||
| `availabilityObjectUniversalIdentifier` | No | Restrict the command to pages of a specific object type (e.g. only on Company records) |
|
||
| `conditionalAvailabilityExpression` | No | A boolean expression to dynamically control whether the command is visible (see below) |
|
||
|
||
#### Conditional availability expressions
|
||
|
||
The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions:
|
||
|
||
```tsx
|
||
import {
|
||
defineFrontComponent,
|
||
pageType,
|
||
numberOfSelectedRecords,
|
||
objectPermissions,
|
||
everyEquals,
|
||
isDefined,
|
||
} from 'twenty-sdk';
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'bulk-action',
|
||
component: BulkAction,
|
||
command: {
|
||
universalIdentifier: '...',
|
||
label: 'Bulk Update',
|
||
availabilityType: 'RECORD_SELECTION',
|
||
conditionalAvailabilityExpression: everyEquals(
|
||
objectPermissions,
|
||
'canUpdateObjectRecords',
|
||
true,
|
||
),
|
||
},
|
||
});
|
||
```
|
||
|
||
**Context variables** — these represent the current state of the page:
|
||
|
||
| Variable | Type | Description |
|
||
|----------|------|-------------|
|
||
| `pageType` | `string` | Current page type (e.g. `'RecordIndexPage'`, `'RecordShowPage'`) |
|
||
| `isInSidePanel` | `boolean` | Whether the component is rendered in a side panel |
|
||
| `numberOfSelectedRecords` | `number` | Number of currently selected records |
|
||
| `isSelectAll` | `boolean` | Whether "select all" is active |
|
||
| `selectedRecords` | `array` | The selected record objects |
|
||
| `favoriteRecordIds` | `array` | IDs of favorited records |
|
||
| `objectPermissions` | `object` | Permissions for the current object type |
|
||
| `targetObjectReadPermissions` | `object` | Read permissions for the target object |
|
||
| `targetObjectWritePermissions` | `object` | Write permissions for the target object |
|
||
| `featureFlags` | `object` | Active feature flags |
|
||
| `objectMetadataItem` | `object` | Metadata of the current object type |
|
||
| `hasAnySoftDeleteFilterOnView` | `boolean` | Whether the current view has a soft-delete filter |
|
||
|
||
**Operators** — combine variables into boolean expressions:
|
||
|
||
| Operator | Description |
|
||
|----------|-------------|
|
||
| `isDefined(value)` | `true` if the value is not null/undefined |
|
||
| `isNonEmptyString(value)` | `true` if the value is a non-empty string |
|
||
| `includes(array, value)` | `true` if the array contains the value |
|
||
| `includesEvery(array, prop, value)` | `true` if every item's property includes the value |
|
||
| `every(array, prop)` | `true` if the property is truthy on every item |
|
||
| `everyDefined(array, prop)` | `true` if the property is defined on every item |
|
||
| `everyEquals(array, prop, value)` | `true` if the property equals the value on every item |
|
||
| `some(array, prop)` | `true` if the property is truthy on at least one item |
|
||
| `someDefined(array, prop)` | `true` if the property is defined on at least one item |
|
||
| `someEquals(array, prop, value)` | `true` if the property equals the value on at least one item |
|
||
| `someNonEmptyString(array, prop)` | `true` if the property is a non-empty string on at least one item |
|
||
| `none(array, prop)` | `true` if the property is falsy on every item |
|
||
| `noneDefined(array, prop)` | `true` if the property is undefined on every item |
|
||
| `noneEquals(array, prop, value)` | `true` if the property does not equal the value on any item |
|
||
|
||
#### Public assets
|
||
|
||
Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:
|
||
|
||
```tsx
|
||
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk';
|
||
|
||
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'logo',
|
||
component: Logo,
|
||
});
|
||
```
|
||
|
||
See the [public assets section](#accessing-public-assets-with-getpublicasseturl) for details.
|
||
|
||
#### Styling
|
||
|
||
Front components support multiple styling approaches. You can use:
|
||
|
||
- **Inline styles** — `style={{ color: 'red' }}`
|
||
- **Twenty UI components** — import from `twenty-sdk/ui` (Button, Tag, Status, Chip, Avatar, and more)
|
||
- **Emotion** — CSS-in-JS with `@emotion/react`
|
||
- **Styled-components** — `styled.div` patterns
|
||
- **Tailwind CSS** — utility classes
|
||
- **Any CSS-in-JS library** compatible with React
|
||
|
||
```tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
import { Button, Tag, Status } from 'twenty-sdk/ui';
|
||
|
||
const StyledWidget = () => {
|
||
return (
|
||
<div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
|
||
<Button title="Click me" onClick={() => alert('Clicked!')} />
|
||
<Tag text="Active" color="green" />
|
||
<Status color="green" text="Online" />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
|
||
name: 'styled-widget',
|
||
component: StyledWidget,
|
||
});
|
||
```
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="defineSkill" description="Define AI agent skills">
|
||
|
||
Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation:
|
||
|
||
```ts src/skills/example-skill.ts
|
||
import { defineSkill } from 'twenty-sdk';
|
||
|
||
export default defineSkill({
|
||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||
name: 'sales-outreach',
|
||
label: 'Sales Outreach',
|
||
description: 'Guides the AI agent through a structured sales outreach process',
|
||
icon: 'IconBrain',
|
||
content: `You are a sales outreach assistant. When reaching out to a prospect:
|
||
1. Research the company and recent news
|
||
2. Identify the prospect's role and likely pain points
|
||
3. Draft a personalized message referencing specific details
|
||
4. Keep the tone professional but conversational`,
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- `name` is a unique identifier string for the skill (kebab-case recommended).
|
||
- `label` is the human-readable display name shown in the UI.
|
||
- `content` contains the skill instructions — this is the text the AI agent uses.
|
||
- `icon` (optional) sets the icon displayed in the UI.
|
||
- `description` (optional) provides additional context about the skill's purpose.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineAgent" description="Define AI agents with custom prompts">
|
||
|
||
Agents are AI assistants that live inside your workspace. Use `defineAgent()` to create agents with a custom system prompt:
|
||
|
||
```ts src/agents/example-agent.ts
|
||
import { defineAgent } from 'twenty-sdk';
|
||
|
||
export default defineAgent({
|
||
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
|
||
name: 'sales-assistant',
|
||
label: 'Sales Assistant',
|
||
description: 'Helps the sales team draft outreach emails and research prospects',
|
||
icon: 'IconRobot',
|
||
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- `name` is the unique identifier string for the agent (kebab-case recommended).
|
||
- `label` is the display name shown in the UI.
|
||
- `prompt` is the system prompt that defines the agent's behavior.
|
||
- `description` (optional) provides context about what the agent does.
|
||
- `icon` (optional) sets the icon displayed in the UI.
|
||
- `modelId` (optional) overrides the default AI model used by the agent.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineView" description="Define saved views for objects">
|
||
|
||
Views are saved configurations for how records of an object are displayed — including which fields are visible, their order, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app:
|
||
|
||
```ts src/views/example-view.ts
|
||
import { defineView, ViewKey } from 'twenty-sdk';
|
||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
|
||
import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
|
||
|
||
export default defineView({
|
||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||
name: 'All example items',
|
||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||
icon: 'IconList',
|
||
key: ViewKey.INDEX,
|
||
position: 0,
|
||
fields: [
|
||
{
|
||
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
|
||
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||
position: 0,
|
||
isVisible: true,
|
||
size: 200,
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- `objectUniversalIdentifier` specifies which object this view applies to.
|
||
- `key` determines the view type (e.g., `ViewKey.INDEX` for the main list view).
|
||
- `fields` controls which columns appear and their order. Each field references a `fieldMetadataUniversalIdentifier`.
|
||
- You can also define `filters`, `filterGroups`, `groups`, and `fieldGroups` for more advanced configurations.
|
||
- `position` controls the ordering when multiple views exist for the same object.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineNavigationMenuItem" description="Define sidebar navigation links">
|
||
|
||
Navigation menu items add custom entries to the workspace sidebar. Use `defineNavigationMenuItem()` to link to views, external URLs, or objects:
|
||
|
||
```ts src/navigation-menu-items/example-navigation-menu-item.ts
|
||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk';
|
||
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view';
|
||
|
||
export default defineNavigationMenuItem({
|
||
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
|
||
name: 'example-navigation-menu-item',
|
||
icon: 'IconList',
|
||
color: 'blue',
|
||
position: 0,
|
||
type: NavigationMenuItemType.VIEW,
|
||
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- `type` determines what the menu item links to: `NavigationMenuItemType.VIEW` for a saved view, or `NavigationMenuItemType.LINK` for an external URL.
|
||
- For view links, set `viewUniversalIdentifier`. For external links, set `link`.
|
||
- `position` controls the ordering in the sidebar.
|
||
- `icon` and `color` (optional) customize the appearance.
|
||
|
||
</Accordion>
|
||
<Accordion title="definePageLayout" description="Define custom page layouts for record views">
|
||
|
||
Page layouts let you customize how a record detail page looks — which tabs appear, what widgets are inside each tab, and how they are arranged. Use `definePageLayout()` to ship custom layouts with your app:
|
||
|
||
```ts src/page-layouts/example-record-page-layout.ts
|
||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
|
||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
|
||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||
|
||
export default definePageLayout({
|
||
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
|
||
name: 'Example Record Page',
|
||
type: 'RECORD_PAGE',
|
||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||
tabs: [
|
||
{
|
||
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
|
||
title: 'Hello World',
|
||
position: 50,
|
||
icon: 'IconWorld',
|
||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||
widgets: [
|
||
{
|
||
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
|
||
title: 'Hello World',
|
||
type: 'FRONT_COMPONENT',
|
||
configuration: {
|
||
configurationType: 'FRONT_COMPONENT',
|
||
frontComponentUniversalIdentifier:
|
||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- `type` is typically `'RECORD_PAGE'` to customize the detail view of a specific object.
|
||
- `objectUniversalIdentifier` specifies which object this layout applies to.
|
||
- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`CANVAS` for free-form layout).
|
||
- Each `widget` inside a tab can render a front component, a relation list, or other built-in widget types.
|
||
- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
|
||
|
||
</Accordion>
|
||
</AccordionGroup>
|
||
|
||
## Public assets (`public/` folder)
|
||
|
||
The `public/` folder at the root of your app holds static files — images, icons, fonts, or any other assets your app needs at runtime. These files are automatically included in builds, synced during dev mode, and uploaded to the server.
|
||
|
||
Files placed in `public/` are:
|
||
|
||
- **Publicly accessible** — once synced to the server, assets are served at a public URL. No authentication is needed to access them.
|
||
- **Available in front components** — use asset URLs to display images, icons, or any media inside your React components.
|
||
- **Available in logic functions** — reference asset URLs in emails, API responses, or any server-side logic.
|
||
- **Used for marketplace metadata** — the `logoUrl` and `screenshots` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published.
|
||
- **Auto-synced in dev mode** — when you add, update, or delete a file in `public/`, it is synced to the server automatically. No restart needed.
|
||
- **Included in builds** — `yarn twenty build` bundles all public assets into the distribution output.
|
||
|
||
### Accessing public assets with `getPublicAssetUrl`
|
||
|
||
Use the `getPublicAssetUrl` helper from `twenty-sdk` to get the full URL of a file in your `public/` directory. It works in both **logic functions** and **front components**.
|
||
|
||
**In a logic function:**
|
||
|
||
```ts src/logic-functions/send-invoice.ts
|
||
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk';
|
||
|
||
const handler = async (): Promise<any> => {
|
||
const logoUrl = getPublicAssetUrl('logo.png');
|
||
const invoiceUrl = getPublicAssetUrl('templates/invoice.png');
|
||
|
||
// Fetch the file content (no auth required — public endpoint)
|
||
const response = await fetch(invoiceUrl);
|
||
const buffer = await response.arrayBuffer();
|
||
|
||
return { logoUrl, size: buffer.byteLength };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'a1b2c3d4-...',
|
||
name: 'send-invoice',
|
||
description: 'Sends an invoice with the app logo',
|
||
timeoutSeconds: 10,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
**In a front component:**
|
||
|
||
```tsx src/front-components/company-card.tsx
|
||
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk';
|
||
|
||
export default defineFrontComponent(() => {
|
||
const logoUrl = getPublicAssetUrl('logo.png');
|
||
|
||
return <img src={logoUrl} alt="App logo" />;
|
||
});
|
||
```
|
||
|
||
The `path` argument is relative to your app's `public/` folder. Both `getPublicAssetUrl('logo.png')` and `getPublicAssetUrl('public/logo.png')` resolve to the same URL — the `public/` prefix is stripped automatically if present.
|
||
|
||
## Using npm packages
|
||
|
||
You can install and use any npm package in your app. Both logic functions and front components are bundled with [esbuild](https://esbuild.github.io/), which inlines all dependencies into the output — no `node_modules` are needed at runtime.
|
||
|
||
### Installing a package
|
||
|
||
```bash filename="Terminal"
|
||
yarn add axios
|
||
```
|
||
|
||
Then import it in your code:
|
||
|
||
```ts src/logic-functions/fetch-data.ts
|
||
import { defineLogicFunction } from 'twenty-sdk';
|
||
import axios from 'axios';
|
||
|
||
const handler = async (): Promise<any> => {
|
||
const { data } = await axios.get('https://api.example.com/data');
|
||
|
||
return { data };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: '...',
|
||
name: 'fetch-data',
|
||
description: 'Fetches data from an external API',
|
||
timeoutSeconds: 10,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
The same works for front components:
|
||
|
||
```tsx src/front-components/chart.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
import { format } from 'date-fns';
|
||
|
||
const DateWidget = () => {
|
||
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'date-widget',
|
||
component: DateWidget,
|
||
});
|
||
```
|
||
|
||
### How bundling works
|
||
|
||
The build step uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle.
|
||
|
||
**Logic functions** run in a Node.js environment. Node built-in modules (`fs`, `path`, `crypto`, `http`, etc.) are available and do not need to be installed.
|
||
|
||
**Front components** run in a Web Worker. Node built-in modules are **not** available — only browser APIs and npm packages that work in a browser environment.
|
||
|
||
Both environments have `twenty-client-sdk/core` and `twenty-client-sdk/metadata` available as pre-provided modules — these are not bundled but resolved at runtime by the server.
|
||
|
||
## Scaffolding entities with `yarn twenty add`
|
||
|
||
Instead of creating entity files by hand, you can use the interactive scaffolder:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty add
|
||
```
|
||
|
||
This prompts you to pick an entity type and walks you through the required fields. It generates a ready-to-use file with a stable `universalIdentifier` and the correct `defineEntity()` call.
|
||
|
||
You can also pass the entity type directly to skip the first prompt:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty add object
|
||
yarn twenty add logicFunction
|
||
yarn twenty add frontComponent
|
||
```
|
||
|
||
### Available entity types
|
||
|
||
| Entity type | Command | Generated file |
|
||
|-------------|---------|----------------|
|
||
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||
| View | `yarn twenty add view` | `src/views/<name>.ts` |
|
||
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||
|
||
### What the scaffolder generates
|
||
|
||
Each entity type has its own template. For example, `yarn twenty add object` asks for:
|
||
|
||
1. **Name (singular)** — e.g., `invoice`
|
||
2. **Name (plural)** — e.g., `invoices`
|
||
3. **Label (singular)** — auto-populated from the name (e.g., `Invoice`)
|
||
4. **Label (plural)** — auto-populated (e.g., `Invoices`)
|
||
5. **Create a view and navigation item?** — if you answer yes, the scaffolder also generates a matching view and sidebar link for the new object.
|
||
|
||
Other entity types have simpler prompts — most only ask for a name.
|
||
|
||
The `field` entity type is more detailed: it asks for the field name, label, type (from a list of all available field types like `TEXT`, `NUMBER`, `SELECT`, `RELATION`, etc.), and the target object's `universalIdentifier`.
|
||
|
||
### Custom output path
|
||
|
||
Use the `--path` flag to place the generated file in a custom location:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty add logicFunction --path src/custom-folder
|
||
```
|
||
|
||
## Typed API clients (twenty-client-sdk)
|
||
|
||
The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components.
|
||
|
||
| Client | Import | Endpoint | Generated? |
|
||
|--------|--------|----------|------------|
|
||
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — workspace data (records, objects) | Yes, at dev/build time |
|
||
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — workspace config, file uploads | No, ships pre-built |
|
||
|
||
<AccordionGroup>
|
||
<Accordion title="CoreApiClient" description="Query and mutate workspace data (records, objects)">
|
||
|
||
`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it is fully typed to match your objects and fields.
|
||
|
||
```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,
|
||
},
|
||
});
|
||
```
|
||
|
||
The client uses a selection-set syntax: pass `true` to include a field, use `__args` for arguments, and nest objects for relations. You get full autocompletion and type checking based on your workspace schema.
|
||
|
||
<Note>
|
||
**CoreApiClient is generated at dev/build time.** If you use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema and generates a typed client using `@genql/cli`.
|
||
</Note>
|
||
|
||
#### Using CoreSchema for type annotations
|
||
|
||
`CoreSchema` provides TypeScript types matching your workspace objects — useful for typing component state or function parameters:
|
||
|
||
```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="Workspace config, applications, and file uploads">
|
||
|
||
`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads.
|
||
|
||
```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 },
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Uploading files
|
||
|
||
`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields:
|
||
|
||
```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://...' }
|
||
```
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `fileBuffer` | `Buffer` | The raw file contents |
|
||
| `filename` | `string` | The name of the file (used for storage and display) |
|
||
| `contentType` | `string` | MIME type (defaults to `application/octet-stream` if omitted) |
|
||
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
|
||
|
||
Key points:
|
||
- Uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed.
|
||
- The returned `url` is a signed URL you can use to access the uploaded file.
|
||
|
||
</Accordion>
|
||
</AccordionGroup>
|
||
|
||
|
||
<Note>
|
||
When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables:
|
||
|
||
- `TWENTY_API_URL` — Base URL of the Twenty API
|
||
- `TWENTY_APP_ACCESS_TOKEN` — Short-lived key scoped to your application's default function role
|
||
|
||
You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`.
|
||
</Note>
|
||
|
||
## Testing your app
|
||
|
||
The SDK provides programmatic APIs that let you build, deploy, install, and uninstall your app from test code. Combined with [Vitest](https://vitest.dev/) and the typed API clients, you can write integration tests that verify your app works end-to-end against a real Twenty server.
|
||
|
||
### Setup
|
||
|
||
The scaffolded app already includes Vitest. If you set it up manually, install the dependencies:
|
||
|
||
```bash filename="Terminal"
|
||
yarn add -D vitest vite-tsconfig-paths
|
||
```
|
||
|
||
Create a `vitest.config.ts` at the root of your app:
|
||
|
||
```ts vitest.config.ts
|
||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
tsconfigPaths({
|
||
projects: ['tsconfig.spec.json'],
|
||
ignoreConfigErrors: true,
|
||
}),
|
||
],
|
||
test: {
|
||
testTimeout: 120_000,
|
||
hookTimeout: 120_000,
|
||
include: ['src/**/*.integration-test.ts'],
|
||
setupFiles: ['src/__tests__/setup-test.ts'],
|
||
env: {
|
||
TWENTY_API_URL: 'http://localhost:2020',
|
||
TWENTY_API_KEY: 'your-api-key',
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
Create a setup file that verifies the server is reachable before tests run:
|
||
|
||
```ts src/__tests__/setup-test.ts
|
||
import * as fs from 'fs';
|
||
import * as os from 'os';
|
||
import * as path from 'path';
|
||
import { beforeAll } from 'vitest';
|
||
|
||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||
|
||
beforeAll(async () => {
|
||
// Verify the server is running
|
||
const response = await fetch(`${TWENTY_API_URL}/healthz`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
|
||
'Start the server before running integration tests.',
|
||
);
|
||
}
|
||
|
||
// Write a temporary config for the SDK
|
||
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(TEST_CONFIG_DIR, 'config.json'),
|
||
JSON.stringify({
|
||
remotes: {
|
||
local: {
|
||
apiUrl: process.env.TWENTY_API_URL,
|
||
apiKey: process.env.TWENTY_API_KEY,
|
||
},
|
||
},
|
||
defaultRemote: 'local',
|
||
}, null, 2),
|
||
);
|
||
});
|
||
```
|
||
|
||
### Programmatic SDK APIs
|
||
|
||
The `twenty-sdk/cli` subpath exports functions you can call directly from test code:
|
||
|
||
| Function | Description |
|
||
|----------|-------------|
|
||
| `appBuild` | Build the app and optionally pack a tarball |
|
||
| `appDeploy` | Upload a tarball to the server |
|
||
| `appInstall` | Install the app on the active workspace |
|
||
| `appUninstall` | Uninstall the app from the active workspace |
|
||
|
||
Each function returns a result object with `success: boolean` and either `data` or `error`.
|
||
|
||
### Writing an integration test
|
||
|
||
Here is a full example that builds, deploys, and installs the app, then verifies it appears in the workspace:
|
||
|
||
```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();
|
||
});
|
||
});
|
||
```
|
||
|
||
### Running tests
|
||
|
||
Make sure your local Twenty server is running, then:
|
||
|
||
```bash filename="Terminal"
|
||
yarn test
|
||
```
|
||
|
||
Or in watch mode during development:
|
||
|
||
```bash filename="Terminal"
|
||
yarn test:watch
|
||
```
|
||
|
||
### Type checking
|
||
|
||
You can also run type checking on your app without running tests:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty typecheck
|
||
```
|
||
|
||
This runs `tsc --noEmit` and reports any type errors.
|
||
|
||
## CLI reference
|
||
|
||
Beyond `dev`, `build`, `add`, and `typecheck`, the CLI provides commands for executing functions, viewing logs, and managing app installations.
|
||
|
||
### Executing functions (`yarn twenty exec`)
|
||
|
||
Run a logic function manually without triggering it via HTTP, cron, or database event:
|
||
|
||
```bash filename="Terminal"
|
||
# Execute by function name
|
||
yarn twenty exec -n create-new-post-card
|
||
|
||
# Execute by universalIdentifier
|
||
yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||
|
||
# Pass a JSON payload
|
||
yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}'
|
||
|
||
# Execute the post-install function
|
||
yarn twenty exec --postInstall
|
||
```
|
||
|
||
### Viewing function logs (`yarn twenty logs`)
|
||
|
||
Stream execution logs for your app's logic functions:
|
||
|
||
```bash filename="Terminal"
|
||
# Stream all function logs
|
||
yarn twenty logs
|
||
|
||
# Filter by function name
|
||
yarn twenty logs -n create-new-post-card
|
||
|
||
# Filter by universalIdentifier
|
||
yarn twenty logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||
```
|
||
|
||
<Note>
|
||
This is different from `yarn twenty server logs`, which shows the Docker container logs. `yarn twenty logs` shows your app's function execution logs from the Twenty server.
|
||
</Note>
|
||
|
||
### Uninstalling an app (`yarn twenty uninstall`)
|
||
|
||
Remove your app from the active workspace:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty uninstall
|
||
|
||
# Skip the confirmation prompt
|
||
yarn twenty uninstall --yes
|
||
```
|
||
|
||
## Managing remotes
|
||
|
||
A **remote** is a Twenty server that your app connects to. During setup, the scaffolder creates one for you automatically. You can add more remotes or switch between them at any time.
|
||
|
||
```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 --api-url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
|
||
|
||
# List all configured remotes
|
||
yarn twenty remote list
|
||
|
||
# Switch the active remote
|
||
yarn twenty remote switch <name>
|
||
```
|
||
|
||
Your credentials are stored in `~/.twenty/config.json`.
|
||
|
||
## CI with GitHub Actions
|
||
|
||
The scaffolder generates a ready-to-use GitHub Actions workflow at `.github/workflows/ci.yml`. It runs your integration tests automatically on every push to `main` and on pull requests.
|
||
|
||
The workflow:
|
||
|
||
1. Checks out your code
|
||
2. Spins up a temporary Twenty server using the `twentyhq/twenty/.github/actions/spawn-twenty-docker-image` action
|
||
3. Installs dependencies with `yarn install --immutable`
|
||
4. Runs `yarn test` with `TWENTY_API_URL` and `TWENTY_API_KEY` injected from the action outputs
|
||
|
||
```yaml .github/workflows/ci.yml
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches:
|
||
- main
|
||
pull_request: {}
|
||
|
||
env:
|
||
TWENTY_VERSION: latest
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Spawn Twenty instance
|
||
id: twenty
|
||
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
|
||
with:
|
||
twenty-version: ${{ env.TWENTY_VERSION }}
|
||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Enable Corepack
|
||
run: corepack enable
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v4
|
||
with:
|
||
node-version-file: '.nvmrc'
|
||
cache: 'yarn'
|
||
|
||
- name: Install dependencies
|
||
run: yarn install --immutable
|
||
|
||
- name: Run integration tests
|
||
run: yarn test
|
||
env:
|
||
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
|
||
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
|
||
```
|
||
|
||
You don't need to configure any secrets — the `spawn-twenty-docker-image` action starts an ephemeral Twenty server directly in the runner and outputs the connection details. The `GITHUB_TOKEN` secret is provided automatically by GitHub.
|
||
|
||
To pin a specific Twenty version instead of `latest`, change the `TWENTY_VERSION` environment variable at the top of the workflow.
|