Files
twenty/packages/twenty-docs/developers/extend/apps/building.mdx
T
martmullGitHubcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
16e3e38b79 Improve getting started doc (#19138)
- improves
`packages/twenty-docs/developers/extend/apps/getting-started.mdx`

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-04-01 20:39:44 +00:00

1717 lines
64 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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**: keyvalue 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: '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 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: false,
},
/*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="definePreInstallLogicFunction" description="Define a pre-install logic function (one per app)">
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.
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: 'e0604b9e-e946-456b-886d-3f27d9a6b324',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
```
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.
</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 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.
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
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.
</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.
#### 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`, 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 components (`isHeadless: true`)
Headless components render no visible UI but still run React logic. This is useful for **effect components** — components that perform side effects when mounted, such as syncing data, starting a timer, listening to events, or triggering a notification.
```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.
#### 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 |
#### 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 (`yarn twenty dev` or `yarn twenty build`) 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 pre-install or post-install functions
yarn twenty exec --preInstall
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
```