fixes https://discord.com/channels/1130383047699738754/1488094970241089586
949 lines
38 KiB
Plaintext
949 lines
38 KiB
Plaintext
---
|
||
title: Building Apps
|
||
description: Define objects, logic functions, front components, and more with the Twenty SDK.
|
||
---
|
||
|
||
<Warning>
|
||
Apps are currently in alpha testing. The feature is functional but still evolving.
|
||
</Warning>
|
||
|
||
## Use the SDK resources (types & config)
|
||
|
||
The twenty-sdk provides typed building blocks and helper functions you use inside your app. Below are the key pieces you'll touch most often.
|
||
|
||
### Helper functions
|
||
|
||
The SDK provides helper functions for defining your app entities. As described in [Entity detection](/developers/extend/apps/getting-started#entity-detection), you must use `export default define<Entity>({...})` for your entities to be detected:
|
||
|
||
| Function | Purpose |
|
||
|----------|---------|
|
||
| `defineApplication` | Configure application metadata (required, one per app) |
|
||
| `defineObject` | Define custom objects with fields |
|
||
| `defineField` | Extend existing objects with additional fields or define standalone relation fields |
|
||
| `defineLogicFunction` | Define logic functions with handlers |
|
||
| `definePreInstallLogicFunction` | Define a pre-install logic function (one per app) |
|
||
| `definePostInstallLogicFunction` | Define a post-install logic function (one per app) |
|
||
| `defineFrontComponent` | Define front components for custom UI |
|
||
| `defineRole` | Configure role permissions and object access |
|
||
| `defineView` | Define saved views for objects |
|
||
| `defineNavigationMenuItem` | Define sidebar navigation links |
|
||
| `defineSkill` | Define AI agent skills |
|
||
| `defineAgent` | Define AI agents |
|
||
| `definePageLayout` | Define custom page layouts |
|
||
|
||
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
|
||
|
||
### Defining objects
|
||
|
||
Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation:
|
||
|
||
```typescript
|
||
// src/objects/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>
|
||
|
||
### Defining fields on existing objects
|
||
|
||
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:
|
||
|
||
```typescript
|
||
// 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()`.
|
||
|
||
### 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):
|
||
|
||
```typescript
|
||
// 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):
|
||
|
||
```typescript
|
||
// 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`:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
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
|
||
],
|
||
});
|
||
```
|
||
|
||
### Application config (application-config.ts)
|
||
|
||
Every app has a single `application-config.ts` file that describes:
|
||
|
||
- **Who the app is**: identifiers, display name, and description.
|
||
- **How its functions run**: which role they use for permissions.
|
||
- **(Optional) variables**: key–value pairs exposed to your functions as environment variables.
|
||
- **(Optional) pre-install function**: a logic function that runs before the app is installed.
|
||
- **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||
|
||
Use `defineApplication()` to define your application configuration:
|
||
|
||
```typescript
|
||
// src/application-config.ts
|
||
import { defineApplication } from 'twenty-sdk';
|
||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||
|
||
export default defineApplication({
|
||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||
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 (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
|
||
- `defaultRoleUniversalIdentifier` must match the role file (see below).
|
||
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
|
||
|
||
#### Marketplace metadata
|
||
|
||
If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how your app appears in the marketplace:
|
||
|
||
| Field | Description |
|
||
|-------|-------------|
|
||
| `author` | Author or company name |
|
||
| `category` | App category for marketplace filtering |
|
||
| `logoUrl` | Path to your app logo (relative to `./assets/`) |
|
||
| `screenshots` | Array of screenshot paths (relative to `./assets/`) |
|
||
| `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
|
||
|
||
Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions.
|
||
|
||
- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role.
|
||
- The typed client will be restricted to the permissions granted to that role.
|
||
- Follow least-privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
|
||
|
||
##### Default function role (*.role.ts)
|
||
|
||
When you scaffold a new app, the CLI also creates a default role file. Use `defineRole()` to define roles with built-in validation:
|
||
|
||
```typescript
|
||
// 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: false,
|
||
canUpdateAllObjectRecords: false,
|
||
canSoftDeleteAllObjectRecords: false,
|
||
canDestroyAllObjectRecords: false,
|
||
canUpdateAllSettings: false,
|
||
canBeAssignedToAgents: false,
|
||
canBeAssignedToUsers: false,
|
||
canBeAssignedToApiKeys: false,
|
||
objectPermissions: [
|
||
{
|
||
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
|
||
canReadObjectRecords: true,
|
||
canUpdateObjectRecords: true,
|
||
canSoftDeleteObjectRecords: false,
|
||
canDestroyObjectRecords: false,
|
||
},
|
||
],
|
||
fieldPermissions: [
|
||
{
|
||
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
|
||
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
|
||
canReadFieldValue: false,
|
||
canUpdateFieldValue: false,
|
||
},
|
||
],
|
||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||
});
|
||
```
|
||
|
||
The `universalIdentifier` of this role is then referenced in `application-config.ts` as `defaultRoleUniversalIdentifier`. In other words:
|
||
|
||
- **\*.role.ts** defines what the default function 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 the `objectPermissions` and `fieldPermissions` with the objects/fields your functions need.
|
||
- `permissionFlags` control access to platform-level capabilities. Keep them minimal; add only what you need.
|
||
- See a working example in the Hello World app: [`packages/twenty-apps/hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
|
||
|
||
### Logic function config and entrypoint
|
||
|
||
Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers.
|
||
|
||
```typescript
|
||
// 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-sdk/generated';
|
||
|
||
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,
|
||
triggers: [
|
||
// Public HTTP route trigger '/s/post-card/create'
|
||
{
|
||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||
type: 'route',
|
||
path: '/post-card/create',
|
||
httpMethod: 'GET',
|
||
isAuthRequired: false,
|
||
},
|
||
// Cron trigger (CRON pattern)
|
||
// {
|
||
// universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||
// type: 'cron',
|
||
// pattern: '0 0 1 1 *',
|
||
// },
|
||
// Database event trigger
|
||
// {
|
||
// universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||
// type: 'databaseEvent',
|
||
// eventName: 'person.updated',
|
||
// updatedFields: ['name'],
|
||
// },
|
||
],
|
||
});
|
||
```
|
||
|
||
Common trigger types:
|
||
- **route**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**:
|
||
> e.g. `path: '/post-card/create',` -> call on `<APP_URL>/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`
|
||
|
||
Notes:
|
||
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
|
||
- You can mix multiple trigger types in a single function.
|
||
|
||
### Pre-install functions
|
||
|
||
A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds.
|
||
|
||
When you scaffold a new app with `create-twenty-app`, a pre-install function is generated for you at `src/logic-functions/pre-install.ts`:
|
||
|
||
```typescript
|
||
// src/logic-functions/pre-install.ts
|
||
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||
|
||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
||
};
|
||
|
||
export default definePreInstallLogicFunction({
|
||
universalIdentifier: '<generated-uuid>',
|
||
name: 'pre-install',
|
||
description: 'Runs before installation to prepare the application.',
|
||
timeoutSeconds: 300,
|
||
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()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
|
||
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
|
||
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
|
||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
|
||
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `exec --preInstall`.
|
||
|
||
### Post-install functions
|
||
|
||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||
|
||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||
|
||
```typescript
|
||
// src/logic-functions/post-install.ts
|
||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||
|
||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||
};
|
||
|
||
export default definePostInstallLogicFunction({
|
||
universalIdentifier: '<generated-uuid>',
|
||
name: 'post-install',
|
||
description: 'Runs after installation to set up the application.',
|
||
timeoutSeconds: 300,
|
||
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 `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
|
||
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
|
||
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
|
||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `exec --postInstall`.
|
||
|
||
### Route trigger payload
|
||
|
||
<Warning>
|
||
**Breaking change (v1.16, January 2026):** The route trigger payload format has changed. Prior to v1.16, query parameters, path parameters, and body were sent directly as the payload. Starting with v1.16, they are nested inside a structured `RoutePayload` object.
|
||
|
||
**Before v1.16:**
|
||
```typescript
|
||
const handler = async (params) => {
|
||
const { param1, param2 } = params; // Direct access
|
||
};
|
||
```
|
||
|
||
**After v1.16:**
|
||
```typescript
|
||
const handler = async (event: RoutePayload) => {
|
||
const { param1, param2 } = event.body; // Access via .body
|
||
const { queryParam } = event.queryStringParameters;
|
||
const { id } = event.pathParameters;
|
||
};
|
||
```
|
||
|
||
**To migrate existing functions:** Update your handler to destructure from `event.body`, `event.queryStringParameters`, or `event.pathParameters` instead of directly from the params object.
|
||
</Warning>
|
||
|
||
When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the AWS HTTP API v2 format. Import the type from `twenty-sdk`:
|
||
|
||
```typescript
|
||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
|
||
|
||
const handler = async (event: RoutePayload) => {
|
||
// Access request data
|
||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||
|
||
// HTTP method and path are available in requestContext
|
||
const { method, path } = event.requestContext.http;
|
||
|
||
return { message: 'Success' };
|
||
};
|
||
```
|
||
|
||
The `RoutePayload` type has the following structure:
|
||
|
||
| Property | Type | Description |
|
||
|----------|------|-------------|
|
||
| `headers` | `Record<string, string \| undefined>` | HTTP headers (only those listed in `forwardedRequestHeaders`) |
|
||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) |
|
||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` -> `{ id: '123' }`) |
|
||
| `body` | `object \| null` | Parsed request body (JSON) |
|
||
| `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 |
|
||
|
||
### Forwarding HTTP headers
|
||
|
||
By default, HTTP headers from incoming requests are **not** passed to your logic function for security reasons. To access specific headers, explicitly list them in the `forwardedRequestHeaders` array:
|
||
|
||
```typescript
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||
name: 'webhook-handler',
|
||
handler,
|
||
triggers: [
|
||
{
|
||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||
type: 'route',
|
||
path: '/webhook',
|
||
httpMethod: 'POST',
|
||
isAuthRequired: false,
|
||
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
In your handler, you can then access these headers:
|
||
|
||
```typescript
|
||
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 (for example, `event.headers['content-type']`).
|
||
</Note>
|
||
|
||
You can create new functions in two ways:
|
||
|
||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
|
||
- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern.
|
||
|
||
### Marking a logic function as a tool
|
||
|
||
Logic functions can be exposed as **tools** for AI agents and workflows. When a function is marked as a tool, it becomes discoverable by Twenty's AI features and can be selected as a step in workflow automations.
|
||
|
||
To mark a logic function as a tool, set `isTool: true` and provide a `toolInputSchema` describing the expected input parameters using [JSON Schema](https://json-schema.org/):
|
||
|
||
```typescript
|
||
// 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,
|
||
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'],
|
||
},
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
|
||
- **`isTool`** (`boolean`, default: `false`): When set to `true`, the function is registered as a tool and becomes available to AI agents and workflow automations.
|
||
- **`toolInputSchema`** (`object`, optional): A JSON Schema object that describes the parameters your function accepts. AI agents use this schema to understand what inputs the tool expects and to validate calls. If omitted, the schema defaults to `{ type: 'object', properties: {} }` (no parameters).
|
||
- Functions with `isTool: false` (or unset) are **not** exposed as tools. They can still be executed directly or called by other functions, but will not appear in tool discovery.
|
||
- **Tool naming**: When exposed as a tool, the function name is automatically normalized to `logic_function_<name>` (lowercased, non-alphanumeric characters replaced with underscores). For example, `enrich-company` becomes `logic_function_enrich_company`.
|
||
- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events (cron, database events, routes) at the same time.
|
||
|
||
<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>
|
||
|
||
### Front components
|
||
|
||
Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation:
|
||
|
||
```typescript
|
||
// src/front-components/my-widget.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
|
||
const MyWidget = () => {
|
||
return (
|
||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||
<h1>My Custom Widget</h1>
|
||
<p>This is a custom front component for Twenty.</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||
name: 'my-widget',
|
||
description: 'A custom widget component',
|
||
component: MyWidget,
|
||
});
|
||
```
|
||
|
||
Key points:
|
||
- Front components are React components that render in isolated contexts within Twenty.
|
||
- The `component` field references your React component.
|
||
- Components are built and synced automatically during `yarn twenty dev`.
|
||
|
||
You can create new front components in two ways:
|
||
|
||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new front component.
|
||
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
|
||
|
||
### Skills
|
||
|
||
Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation:
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
You can create new skills in two ways:
|
||
|
||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new skill.
|
||
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
|
||
|
||
### 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 |
|
||
|
||
#### CoreApiClient
|
||
|
||
`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's fully typed to match your objects and fields.
|
||
|
||
```typescript
|
||
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: 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 try to 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, generates a typed client using `@genql/cli`, writes the generated sources to `node_modules/twenty-client-sdk/dist/core/generated/`, and replaces the stubs in `node_modules/twenty-client-sdk/dist/core.mjs` and `node_modules/twenty-client-sdk/dist/core.cjs`.
|
||
</Note>
|
||
|
||
#### Using CoreSchema for type annotations
|
||
|
||
`CoreSchema` provides TypeScript types matching your workspace objects, useful for typing component state or function parameters:
|
||
|
||
```typescript
|
||
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||
import { useState } from 'react';
|
||
|
||
const [company, setCompany] = useState<
|
||
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
|
||
>(undefined);
|
||
|
||
const client = new CoreApiClient();
|
||
const result = await client.query({
|
||
company: {
|
||
__args: { filter: { position: { eq: 1 } } },
|
||
id: true,
|
||
name: true,
|
||
},
|
||
});
|
||
setCompany(result.company);
|
||
```
|
||
|
||
#### MetadataApiClient
|
||
|
||
`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads:
|
||
|
||
```typescript
|
||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||
|
||
const metadataClient = new MetadataApiClient();
|
||
|
||
// Query workspace info
|
||
const { currentWorkspace } = await metadataClient.query({
|
||
currentWorkspace: { id: true, displayName: true },
|
||
});
|
||
|
||
// List installed applications
|
||
const { findManyApplications } = await metadataClient.query({
|
||
findManyApplications: {
|
||
id: true,
|
||
name: true,
|
||
version: true,
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Runtime credentials
|
||
|
||
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_API_KEY` — 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`.
|
||
|
||
#### Uploading files
|
||
|
||
`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields. It implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec):
|
||
|
||
```typescript
|
||
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 of the file (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.
|
||
|
||
### Hello World example
|
||
|
||
Explore a minimal, end-to-end example that demonstrates objects, logic functions, front components, and multiple triggers [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world).
|