snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
---
title: Best Practices
---
This document outlines the best practices you should follow when working on the backend.
## Follow a modular approach
The backend follows a modular approach, which is a fundamental principle when working with NestJS. Make sure you break down your code into reusable modules to maintain a clean and organized codebase.
Each module should encapsulate a particular feature or functionality and have a well-defined scope. This modular approach enables clear separation of concerns and removes unnecessary complexities.
## Expose services to use in modules
Always create services that have a clear and single responsibility, which enhances code readability and maintainability. Name the services descriptively and consistently.
You should also expose services that you want to use in other modules. Exposing services to other modules is possible through NestJS's powerful dependency injection system, and promotes loose coupling between components.
## Avoid using `any` type
When you declare a variable as `any`, TypeScript's type checker doesn't perform any type checking, making it possible to assign any type of values to the variable. TypeScript uses type inference to determine the type of variable based on the value. By declaring it as `any`, TypeScript can no longer infer the type. This makes it hard to catch type-related errors during development, leading to runtime errors and makes the code less maintainable, less reliable, and harder to understand for others.
This is why everything should have a type. So if you create a new object with a first name and last name, you should create an interface or type that contains a first name and last name that defines the shape of the object you are manipulating.

View File

@@ -0,0 +1,41 @@
---
title: Custom Objects
---
Objects are structures that allow you to store data (records, attributes, and values) specific to an organization. Twenty provides both standard and custom objects.
Standard objects are in-built objects with a set of attributes available for all users. Examples of standard objects in Twenty include Company and Person. Standard objects have standard fields that are also available for all Twenty users, like Company.displayName.
Custom objects are objects that you can create to store information that is unique to your organization. They are not built-in; members of your workspace can create and customize custom objects to hold information that standard objects aren't suitable for.
## High-level schema
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="High level schema" />
</div>
<br/>
## How it works
Custom objects come from metadata tables that determine the shape, name, and type of the objects. All this information is present in the metadata schema database, consisting of tables:
- **DataSource**: Details where the data is present.
- **Object**: Describes the object and links to a DataSource.
- **Field**: Outlines an Object's fields and connects to the Object.
To add a custom object, the workspaceMember will query the /metadata API. This updates the metadata accordingly and computes a GraphQL schema based on the metadata, storing it in a GQL cache for later use.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/add-custom-objects.jpeg" alt="Query the /metadata API to add custom objects" />
</div>
<br/>
To fetch data, the process involves making queries through the /graphql endpoint and passing them through the Query Resolver.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="Query the /graphql endpoint to fetch data" />
</div>

View File

@@ -0,0 +1,51 @@
---
title: Feature Flags
---
Feature flags are used to hide experimental features. For Twenty, they are set on workspace level and not on a user level.
## Adding a new feature flag
In `FeatureFlagKey.ts` add the feature flag:
```ts
type FeatureFlagKey =
| 'IS_FEATURENAME_ENABLED'
| ...;
```
Also add it to the enum in `feature-flag.entity.ts`:
```ts
enum FeatureFlagKeys {
IsFeatureNameEnabled = 'IS_FEATURENAME_ENABLED',
...
}
```
To apply a feature flag on a **backend** feature use:
```ts
@Gate({
featureFlag: 'IS_FEATURENAME_ENABLED',
})
```
To apply a feature flag on a **frontend** feature use:
```ts
const isFeatureNameEnabled = useIsFeatureEnabled('IS_FEATURENAME_ENABLED');
```
## Configure feature flags for the deployment
Change the corresponding record in the Table `core.featureFlag`:
| id | key | workspaceId | value |
|----------|--------------------------|---------------|--------|
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |

View File

@@ -0,0 +1,127 @@
---
title: Folder Architecture
info: A detailed look into our server folder architecture
---
The backend directory structure is as follows:
```
server
└───ability
└───constants
└───core
└───database
└───decorators
└───filters
└───guards
└───health
└───integrations
└───metadata
└───workspace
└───utils
```
## Ability
Defines permissions and includes handlers for each entity.
## Decorators
Defines custom decorators in NestJS for added functionality.
See [custom decorators](https://docs.nestjs.com/custom-decorators) for more details.
## Filters
Includes exception filters to handle exceptions that might occur in GraphQL endpoints.
## Guards
See [guards](https://docs.nestjs.com/guards) for more details.
## Health
Includes a publicly available REST API (healthz) that returns a JSON to confirm whether the database is working as expected.
## Metadata
Defines custom objects and makes available a GraphQL API (graphql/metadata).
## Workspace
Generates and serves custom GraphQL schema based on the metadata.
### Workspace Directory Structure
```
workspace
└───workspace-schema-builder
└───factories
└───graphql-types
└───database
└───interfaces
└───object-definitions
└───services
└───storage
└───utils
└───workspace-resolver-builder
└───factories
└───interfaces
└───workspace-query-builder
└───factories
└───interfaces
└───workspace-query-runner
└───interfaces
└───utils
└───workspace-datasource
└───workspace-manager
└───workspace-migration-runner
└───utils
└───workspace.module.ts
└───workspace.factory.spec.ts
└───workspace.factory.ts
```
The root of the workspace directory includes the `workspace.factory.ts`, a file containing the `createGraphQLSchema` function. This function generates workspace-specific schema by using the metadata to tailor a schema for individual workspaces. By separating the schema and resolver construction, we use the `makeExecutableSchema` function, which combines these discrete elements.
This strategy is not just about organization, but also helps with optimization, such as caching generated type definitions to enhance performance and scalability.
### Workspace Schema builder
Generates the GraphQL schema, and includes:
#### Factories:
Specialised constructors to generate GraphQL-related constructs.
- The type.factory translates field metadata into GraphQL types using `TypeMapperService`.
- The type-definition.factory creates GraphQL input or output objects derived from `objectMetadata`.
#### GraphQL Types
Includes enumerations, inputs, objects, and scalars, and serves as the building blocks for the schema construction.
#### Interfaces and Object Definitions
Contains the blueprints for GraphQL entities, and includes both predefined and custom types like `MONEY` or `URL`.
#### Services
Contains the service responsible for associating FieldMetadataType with its appropriate GraphQL scalar or query modifiers.
#### Storage
Includes the `TypeDefinitionsStorage` class that contains reusable type definitions, preventing duplication of GraphQL types.
### Workspace Resolver Builder
Creates resolver functions for querying and mutating the GraphQL schema.
Each factory in this directory is responsible for producing a distinct resolver type, such as the `FindManyResolverFactory`, designed for adaptable application across various tables.
### Workspace Query Runner
Runs the generated queries on the database and parses the result.

View File

@@ -0,0 +1,43 @@
---
title: Message Queue
---
Queues facilitate async operations to be performed. They can be used for performing background tasks such as sending a welcome email on register.
Each use case will have its own queue class extended from `MessageQueueServiceBase`.
Currently, we only support `bull-mq`[bull-mq](https://bullmq.io/) as the queue driver.
## Steps to create and use a new queue
1. Add a queue name for your new queue under enum `MESSAGE_QUEUES`.
2. Provide the factory implementation of the queue with the queue name as the dependency token.
3. Inject the queue that you created in the required module/service with the queue name as the dependency token.
4. Add worker class with token based injection just like producer.
### Example usage
```ts
class Resolver {
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {}
async onSomeAction() {
//business logic
await this.queue.add(someData);
}
}
//async worker
class CustomWorker {
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {
this.initWorker();
}
async initWorker() {
await this.queue.work(async ({ id, data }) => {
//worker logic
});
}
}
```

View File

@@ -0,0 +1,84 @@
---
title: Backend Commands
icon: "terminal"
---
## Useful commands
These commands should be executed from packages/twenty-server folder.
From any other folder you can run `npx nx {command} twenty-server` (or `npx nx run twenty-server:{command}`).
### First time setup
```
npx nx database:reset twenty-server # setup the database with dev seeds
```
### Starting the server
```
npx nx run twenty-server:start
```
### Lint
```
npx nx run twenty-server:lint # pass --fix to fix lint errors
```
### Test
```
npx nx run twenty-server:test:unit # run unit tests
npx nx run twenty-server:test:integration # run integration tests
```
Note: you can run `npx nx run twenty-server:test:integration:with-db-reset` in case you need to reset the database before running the integration tests.
### Resetting the database
If you want to reset and seed the database, you can run the following command:
```bash
npx nx run twenty-server:database:reset
```
### Migrations
#### For objects in Core/Metadata schemas (TypeORM)
```bash
npx nx run twenty-server:database:migrate:generate
```
## Tech Stack
Twenty primarily uses NestJS for the backend.
Prisma was the first ORM we used. But in order to allow users to create custom fields and custom objects, a lower-level made more sense as we need to have fine-grained control. The project now uses TypeORM.
Here's what the tech stack now looks like.
**Core**
- [NestJS](https://nestjs.com/)
- [TypeORM](https://typeorm.io/)
- [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
**Database**
- [Postgres](https://www.postgresql.org/)
**Third-party integrations**
- [Sentry](https://sentry.io/welcome/) for tracking bugs
**Testing**
- [Jest](https://jestjs.io/)
**Tooling**
- [Yarn](https://yarnpkg.com/)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
**Development**
- [AWS EKS](https://aws.amazon.com/eks/)

View File

@@ -0,0 +1,76 @@
---
title: Zapier App
---
Effortlessly sync Twenty with 3000+ apps using [Zapier](https://zapier.com/). Automate tasks, boost productivity, and supercharge your customer relationships!
## About Zapier
Zapier is a tool that allows you to automate workflows by connecting the apps that your team uses every day. The fundamental concept of Zapier is automation workflows, called Zaps, and include triggers and actions.
You can learn more about how Zapier works [here](https://zapier.com/how-it-works).
## Setup
### Step 1: Install Zapier packages
```bash
cd packages/twenty-zapier
yarn
```
### Step 2: Login with the CLI
Use your Zapier credentials to log in using the CLI:
```bash
zapier login
```
### Step 3: Set environment variables
From the `packages/twenty-zapier` folder, run:
```bash
cp .env.example .env
```
Run the application locally, go to [http://localhost:3000/settings/api-webhooks](http://localhost:3000/settings/api-webhooks), and generate an API key.
Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated.
## Development
<Warning>
Make sure to run `yarn build` before any `zapier` command.
</Warning>
### Test
```bash
yarn test
```
### Lint
```bash
yarn format
```
### Watch and compile as you edit code
```bash
yarn watch
```
### Validate your Zapier app
```bash
yarn validate
```
### Deploy your Zapier app
```bash
yarn deploy
```
### List all Zapier CLI commands
```bash
zapier
```

View File

@@ -0,0 +1,77 @@
---
title: Bugs, Requests & Pull Requests
icon: "bug"
info: Report issues, request features, and contribute code
---
## Reporting Bugs
To report a bug, please [create an issue on GitHub](https://github.com/twentyhq/twenty/issues/new).
You can also ask for help on [Discord](https://discord.gg/cx5n4Jzs57).
## Feature Requests
If you're not sure if it's a bug, and you feel it's closer to a feature request, then you should probably [open a discussion instead](https://github.com/twentyhq/twenty/discussions/new).
## Submit a Pull Request
Contributing code to Twenty starts with a pull request (PR).
### Before You Start
1. Check [existing issues](https://github.com/twentyhq/twenty/issues) for related work
2. For new features, open an issue first to discuss
3. Review our [Code of Conduct](https://github.com/twentyhq/twenty/blob/main/.github/CODE_OF_CONDUCT.md)
### Fork and Clone
1. Fork the repository on GitHub
2. Clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/twenty.git
cd twenty
```
3. Add upstream remote:
```bash
git remote add upstream https://github.com/twentyhq/twenty.git
```
### Create a Branch
```bash
git checkout -b feature/your-feature-name
```
Use descriptive branch names:
- `feature/add-export-button`
- `fix/login-redirect-issue`
- `docs/update-api-guide`
### Make Your Changes
1. Write clean, well-documented code
2. Follow existing code style
3. Add tests for new functionality
4. Update documentation if needed
### Submit Your PR
1. Push your branch:
```bash
git push origin feature/your-feature-name
```
2. Open a PR on GitHub
3. Fill in the PR template
4. Link related issues
### PR Checklist
- [ ] Code follows project style guidelines
- [ ] Tests pass locally
- [ ] Documentation is updated
- [ ] PR description explains the changes

View File

@@ -0,0 +1,331 @@
---
title: Best Practices
icon: "star"
---
This document outlines the best practices you should follow when working on the frontend.
## State management
React and Jotai handle state management in the codebase.
### Use Jotai atoms to store state
It's good practice to create as many atoms as you need to store your state.
<Warning>
It's better to use extra atoms than trying to be too concise with props drilling.
</Warning>
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
key: 'myAtomState',
defaultValue: 'default value',
});
export const MyComponent = () => {
const [myAtom, setMyAtom] = useAtomState(myAtomState);
return (
<div>
<input
value={myAtom}
onChange={(e) => setMyAtom(e.target.value)}
/>
</div>
);
}
```
### Do not use `useRef` to store state
Avoid using `useRef` to store state.
If you want to store state, you should use `useState` or Jotai atoms with `useAtomState`.
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
## Managing re-renders
Re-renders can be hard to manage in React.
Here are some rules to follow to avoid unnecessary re-renders.
Keep in mind that you can **always** avoid re-renders by understanding their cause.
### Work at the root level
Avoiding re-renders in new features is now made easy by eliminating them at the root level.
The `PageChangeEffect` sidecar component contains just one `useEffect` that holds all the logic to execute on a page change.
That way you know that there's just one place that can trigger a re-render.
### Always think twice before adding `useEffect` in your codebase
Re-renders are often caused by unnecessary `useEffect`.
You should think whether you need `useEffect`, or if you can move the logic in a event handler function.
You'll find it generally easy to move the logic in a `handleClick` or `handleChange` function.
You can also find them in libraries like Apollo: `onCompleted`, `onError`, etc.
### Use a sibling component to extract `useEffect` or data fetching logic
If you feel like you need to add a `useEffect` in your root component, you should consider extracting it in a sidecar component.
You can apply the same for data fetching logic, with Apollo hooks.
```tsx
// ❌ Bad, will cause re-renders even if data is not changing,
// because useEffect needs to be re-evaluated
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
setData(someDependency);
}
}, [someDependency]);
return <div>{data}</div>;
};
export const App = () => (
<PageComponent />
);
```
```tsx
// ✅ Good, will not cause re-renders if data is not changing,
// because useEffect is re-evaluated in another sibling component
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
return <div>{data}</div>;
};
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
setData(someDependency);
}
}, [someDependency]);
return <></>;
};
export const App = () => (
<>
<PageData />
<PageComponent />
</>
);
```
### Use atom family states and selectors
Atom family states and selectors are a great way to avoid re-renders.
They are useful when you need to store a list of items.
### You shouldn't use `React.memo(MyComponent)`
Avoid using `React.memo()` because it does not solve the cause of the re-render, but instead breaks the re-render chain, which can lead to unexpected behavior and make the code very hard to refactor.
### Limit `useCallback` or `useMemo` usage
They are often not necessary and will make the code harder to read and maintain for a gain of performance that is unnoticeable.
## Console.logs
`console.log` statements are valuable during development, offering real-time insights into variable values and code flow. But, leaving them in production code can lead to several issues:
1. **Performance**: Excessive logging can affect the runtime performance, especially on client-side applications.
2. **Security**: Logging sensitive data can expose critical information to anyone who inspects the browser's console.
3. **Cleanliness**: Filling up the console with logs can obscure important warnings or errors that developers or tools need to see.
4. **Professionalism**: End users or clients checking the console and seeing a myriad of log statements might question the code's quality and polish.
Make sure you remove all `console.logs` before pushing the code to production.
## Naming
### Variable Naming
Variable names ought to precisely depict the purpose or function of the variable.
#### The issue with generic names
Generic names in programming are not ideal because they lack specificity, leading to ambiguity and reduced code readability. Such names fail to convey the variable or function's purpose, making it challenging for developers to understand the code's intent without deeper investigation. This can result in increased debugging time, higher susceptibility to errors, and difficulties in maintenance and collaboration. Meanwhile, descriptive naming makes the code self-explanatory and easier to navigate, enhancing code quality and developer productivity.
```tsx
// ❌ Bad, uses a generic name that doesn't communicate its
// purpose or content clearly
const [value, setValue] = useState('');
```
```tsx
// ✅ Good, uses a descriptive name
const [email, setEmail] = useState('');
```
#### Some words to avoid in variable names
- dummy
### Event handlers
Event handler names should start with `handle`, while `on` is a prefix used to name events in components props.
```tsx
// ❌ Bad
const onEmailChange = (val: string) => {
// ...
};
```
```tsx
// ✅ Good
const handleEmailChange = (val: string) => {
// ...
};
```
## Optional Props
Avoid passing the default value for an optional prop.
**EXAMPLE**
Take the`EmailField` component defined below:
```tsx
type EmailFieldProps = {
value: string;
disabled?: boolean;
};
const EmailField = ({ value, disabled = false }: EmailFieldProps) => (
<TextInput value={value} disabled={disabled} fullWidth />
);
```
**Usage**
```tsx
// ❌ Bad, passing in the same value as the default value adds no value
const Form = () => <EmailField value="username@email.com" disabled={false} />;
```
```tsx
// ✅ Good, assumes the default value
const Form = () => <EmailField value="username@email.com" />;
```
## Component as props
Try as much as possible to pass uninstantiated components as props, so children can decide on their own of what props they need to pass.
The most common example for that is icon components:
```tsx
const SomeParentComponent = () => <MyComponent Icon={MyIcon} />;
// In MyComponent
const MyComponent = ({ MyIcon }: { MyIcon: IconComponent }) => {
const theme = useTheme();
return (
<div>
<MyIcon size={theme.icon.size.md}>
</div>
)
};
```
For React to understand that the component is a component, you need to use PascalCase, to later instantiate it with `<MyIcon>`
## Prop Drilling: Keep It Minimal
Prop drilling, in the React context, refers to the practice of passing state variables and their setters through many component layers, even if intermediary components don't use them. While sometimes necessary, excessive prop drilling can lead to:
1. **Decreased Readability**: Tracing where a prop originates or where it's utilized can become convoluted in a deeply nested component structure.
2. **Maintenance Challenges**: Changes in one component's prop structure might require adjustments in several components, even if they don't directly use the prop.
3. **Reduced Component Reusability**: A component receiving a lot of props solely for passing them down becomes less general-purpose and harder to reuse in different contexts.
If you feel that you are using excessive prop drilling, see [state management best practices](#state-management).
## Imports
When importing, opt for the designated aliases rather than specifying complete or relative paths.
**The Aliases**
```js
{
alias: {
"~": path.resolve(__dirname, "src"),
"@": path.resolve(__dirname, "src/modules"),
"@testing": path.resolve(__dirname, "src/testing"),
},
}
```
**Usage**
```tsx
// ❌ Bad, specifies the entire relative path
import {
CatalogDecorator
} from '../../../../../testing/decorators/CatalogDecorator';
import {
ComponentDecorator
} from '../../../../../testing/decorators/ComponentDecorator';
```
```tsx
// ✅ Good, utilises the designated aliases
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from 'twenty-ui/testing';
```
## Schema Validation
[Zod](https://github.com/colinhacks/zod) is the schema validator for untyped objects:
```js
const validationSchema = z
.object({
exist: z.boolean(),
email: z
.string()
.email('Email must be a valid email'),
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
})
.required();
type Form = z.infer<typeof validationSchema>;
```
## Breaking Changes
Always perform thorough manual testing before proceeding to guarantee that modifications haven’t caused disruptions elsewhere, given that tests have not yet been extensively integrated.

View File

@@ -0,0 +1,113 @@
---
title: Folder Architecture
icon: "folder-tree"
info: A detailed look into our folder architecture
---
In this guide, you will explore the details of the project directory structure and how it contributes to the organization and maintainability of Twenty.
By following this folder architecture convention, it's easier to find the files related to specific features and ensure that the application is scalable and maintainable.
```
front
└───modules
│ └───module1
│ │ └───submodule1
│ └───module2
│ └───ui
│ │ └───display
│ │ └───inputs
│ │ │ └───buttons
│ │ └───...
└───pages
└───...
```
## Pages
Includes the top-level components defined by the application routes. They import more low-level components from the modules folder (more details below).
## Modules
Each module represents a feature or a group of feature, comprising its specific components, states, and operational logic.
They should all follow the structure below. You can nest modules within modules (referred to as submodules) and the same rules will apply.
```
module1
└───components
│ └───component1
│ └───component2
└───constants
└───contexts
└───graphql
│ └───fragments
│ └───queries
│ └───mutations
└───hooks
│ └───internal
└───states
│ └───selectors
└───types
└───utils
```
### Contexts
A context is a way to pass data through the component tree without having to pass props down manually at every level.
See [React Context](https://react.dev/reference/react#context-hooks) for more details.
### GraphQL
Includes fragments, queries, and mutations.
See [GraphQL](https://graphql.org/learn/) for more details.
- Fragments
A fragment is a reusable piece of a query, which you can use in different places. By using fragments, it's easier to avoid duplicating code.
See [GraphQL Fragments](https://graphql.org/learn/queries/#fragments) for more details.
- Queries
See [GraphQL Queries](https://graphql.org/learn/queries/) for more details.
- Mutations
See [GraphQL Mutations](https://graphql.org/learn/queries/#mutations) for more details.
### Hooks
See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more details.
### States
Contains the state management logic. [Jotai](https://jotai.org) handles this.
- Selectors: Derived atoms (using `createAtomSelector`) compute values from other atoms and are automatically memoized.
React's built-in state management still handles state within a component.
### Utils
Should just contain reusable pure functions. Otherwise, create custom hooks in the `hooks` folder.
## UI
Contains all the reusable UI components used in the application.
This folder can contain sub-folders, like `data`, `display`, `feedback`, and `input` for specific types of components. Each component should be self-contained and reusable, so that you can use it in different parts of the application.
By separating the UI components from the other components in the `modules` folder, it's easier to maintain a consistent design and to make changes to the UI without affecting other parts (business logic) of the codebase.
## Interface and dependencies
You can import other module code from any module except for the `ui` folder. This will keep its code easy to test.
### Internal
Each part (hooks, states, ...) of a module can have an `internal` folder, which contains parts that are just used within the module.

View File

@@ -0,0 +1,92 @@
---
title: Frontend Commands
icon: "terminal"
---
## Useful commands
### Starting the app
```bash
npx nx start twenty-front
```
### Regenerate graphql schema based on API graphql schema
```bash
npx nx run twenty-front:graphql:generate --configuration=metadata
```
OR
```bash
npx nx run twenty-front:graphql:generate
```
### Lint
```bash
npx nx run twenty-front:lint # pass --fix to fix lint errors
```
## Translations
```bash
npx nx run twenty-front:lingui:extract
npx nx run twenty-front:lingui:compile
```
### Test
```bash
npx nx run twenty-front:test # run jest tests
npx nx run twenty-front:storybook:serve:dev # run storybook
npx nx run twenty-front:storybook:test # run tests # (needs yarn storybook:serve:dev to be running)
npx nx run twenty-front:storybook:coverage # (needs yarn storybook:serve:dev to be running)
```
## Tech Stack
The project has a clean and simple stack, with minimal boilerplate code.
**App**
- [React](https://react.dev/)
- [Apollo](https://www.apollographql.com/docs/)
- [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
- [Jotai](https://jotai.org/)
- [TypeScript](https://www.typescriptlang.org/)
**Testing**
- [Jest](https://jestjs.io/)
- [Storybook](https://storybook.js.org/)
**Tooling**
- [Yarn](https://yarnpkg.com/)
- [Craco](https://craco.js.org/docs/)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
## Architecture
### Routing
[React Router](https://reactrouter.com/) handles the routing.
To avoid unnecessary [re-renders](/developers/contribute/capabilities/frontend-development/best-practices-front#managing-re-renders) all the routing logic is in a `useEffect` in `PageChangeEffect`.
### State Management
[Jotai](https://jotai.org/) handles state management.
See [best practices](/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) for more information on state management.
## Testing
[Jest](https://jestjs.io/) serves as the tool for unit testing while [Storybook](https://storybook.js.org/) is for component testing.
Jest is mainly for testing utility functions, and not components themselves.
Storybook is for testing the behavior of isolated components, as well as displaying the design system.

View File

@@ -0,0 +1,177 @@
---
title: Hotkeys
---
## Introduction
When you need to listen to a hotkey, you would normally use the `onKeyDown` event listener.
In `twenty-front` however, you might have conflicts between same hotkeys that are used in different components, mounted at the same time.
For example, if you have a page that listens for the Enter key, and a modal that listens for the Enter key, with a Select component inside that modal that listens for the Enter key, you might have a conflict when all are mounted at the same time.
## The `useScopedHotkeys` hook
To handle this problem, we have a custom hook that makes it possible to listen to hotkeys without any conflict.
You place it in a component, and it will listen to the hotkeys only when the component is mounted AND when the specified **hotkey scope** is active.
## How to listen for hotkeys in practice?
There are two steps involved in setting up hotkey listening :
1. Set the [hotkey scope](#what-is-a-hotkey-scope-) that will listen to hotkeys
2. Use the `useScopedHotkeys` hook to listen to hotkeys
Setting up hotkey scopes is required even in simple pages, because other UI elements like left menu or command menu might also listen to hotkeys.
## Use cases for hotkeys
In general, you'll have two use cases that require hotkeys :
1. In a page or a component mounted in a page
2. In a modal-type component that takes the focus due to a user action
The second use case can happen recursively : a dropdown in a modal for example.
### Listening to hotkeys in a page
Example :
```tsx
const PageListeningEnter = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
// 1. Set the hotkey scope in a useEffect
useEffect(() => {
setHotkeyScopeAndMemorizePreviousScope(
ExampleHotkeyScopes.ExampleEnterPage,
);
// Revert to the previous hotkey scope when the component is unmounted
return () => {
goBackToPreviousHotkeyScope();
};
}, [goBackToPreviousHotkeyScope, setHotkeyScopeAndMemorizePreviousScope]);
// 2. Use the useScopedHotkeys hook
useScopedHotkeys(
Key.Enter,
() => {
// Some logic executed on this page when the user presses Enter
// ...
},
ExampleHotkeyScopes.ExampleEnterPage,
);
return <div>My page that listens for Enter</div>;
};
```
### Listening to hotkeys in a modal-type component
For this example we'll use a modal component that listens for the Escape key to tell its parent to close it.
Here the user interaction is changing the scope.
```tsx
const ExamplePageWithModal = () => {
const [showModal, setShowModal] = useState(false);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const handleOpenModalClick = () => {
// 1. Set the hotkey scope when user opens the modal
setShowModal(true);
setHotkeyScopeAndMemorizePreviousScope(
ExampleHotkeyScopes.ExampleModal,
);
};
const handleModalClose = () => {
// 1. Revert to the previous hotkey scope when the modal is closed
setShowModal(false);
goBackToPreviousHotkeyScope();
};
return <div>
<h1>My page with a modal</h1>
<button onClick={handleOpenModalClick}>Open modal</button>
{showModal && <MyModalComponent onClose={handleModalClose} />}
</div>;
};
```
Then in the modal component :
```tsx
const MyDropdownComponent = ({ onClose }: { onClose: () => void }) => {
// 2. Use the useScopedHotkeys hook to listen for Escape.
// Note that escape is a common hotkey that could be used by many other components
// So it's important to use a hotkey scope to avoid conflicts
useScopedHotkeys(
Key.Escape,
() => {
onClose()
},
ExampleHotkeyScopes.ExampleModal,
);
return <div>My modal component</div>;
};
```
It's important to use this pattern when you're not sure that just using a useEffect with mount/unmount will be enough to avoid conflicts.
Those conflicts can be hard to debug, and it might happen more often than not with useEffects.
## What is a hotkey scope?
A hotkey scope is a string that represents a context in which the hotkeys are active. It is generally encoded as an enum.
When you change the hotkey scope, the hotkeys that are listening to this scope will be enabled and the hotkeys that are listening to other scopes will be disabled.
You can set only one scope at a time.
As an example, the hotkey scopes for each page are defined in the `PageHotkeyScope` enum:
```tsx
export enum PageHotkeyScope {
Settings = 'settings',
CreateWorkspace = 'create-workspace',
SignInUp = 'sign-in-up',
CreateProfile = 'create-profile',
PlanRequired = 'plan-required',
ShowPage = 'show-page',
PersonShowPage = 'person-show-page',
CompanyShowPage = 'company-show-page',
CompaniesPage = 'companies-page',
PeoplePage = 'people-page',
OpportunitiesPage = 'opportunities-page',
ProfilePage = 'profile-page',
WorkspaceMemberPage = 'workspace-member-page',
TaskPage = 'task-page',
}
```
Internally, the currently selected scope is stored in a Jotai atom that is shared across the application :
```tsx
export const currentHotkeyScopeState = createState<HotkeyScope>({
key: 'currentHotkeyScopeState',
defaultValue: INITIAL_HOTKEYS_SCOPE,
});
```
But this atom should never be handled manually ! We'll see how to use it in the next section.
## How is it working internally?
We made a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders.
We also create a Jotai atom to handle the hotkey scope state and make it available everywhere in the application.

View File

@@ -0,0 +1,9 @@
---
title: Storybook
description: Browse Twenty's UI component library
---
View our complete component library and documentation in Storybook.
[Open Storybook →](https://storybook.twenty.com)

View File

@@ -0,0 +1,291 @@
---
title: Style Guide
icon: "paintbrush"
---
This document includes the rules to follow when writing code.
The goal here is to have a consistent codebase, which is easy to read and easy to maintain.
For this, it's better to be a bit more verbose than to be too concise.
Always keep in mind that people read code more often than they write it, especially on an open source project, where anyone can contribute.
There are a lot of rules that are not defined here, but that are automatically checked by linters.
## React
### Use functional components
Always use TSX functional components.
Do not use default `import` with `const`, because it's harder to read and harder to import with code completion.
```tsx
// ❌ Bad, harder to read, harder to import with code completion
const MyComponent = () => {
return <div>Hello World</div>;
};
export default MyComponent;
// ✅ Good, easy to read, easy to import with code completion
export function MyComponent() {
return <div>Hello World</div>;
};
```
### Props
Create the type of the props and call it `(ComponentName)Props` if there's no need to export it.
Use props destructuring.
```tsx
// ❌ Bad, no type
export const MyComponent = (props) => <div>Hello {props.name}</div>;
// ✅ Good, type
type MyComponentProps = {
name: string;
};
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
```
#### Refrain from using `React.FC` or `React.FunctionComponent` to define prop types
```tsx
/* ❌ - Bad, defines the component type annotations with `FC`
* - With `React.FC`, the component implicitly accepts a `children` prop
* even if it's not defined in the prop type. This might not always be
* desirable, especially if the component doesn't intend to render
* children.
*/
const EmailField: React.FC<{
value: string;
}> = ({ value }) => <TextInput value={value} disabled fullWidth />;
```
```tsx
/* ✅ - Good, a separate type (OwnProps) is explicitly defined for the
* component's props
* - This method doesn't automatically include the children prop. If
* you want to include it, you have to specify it in OwnProps.
*/
type EmailFieldProps = {
value: string;
};
const EmailField = ({ value }: EmailFieldProps) => (
<TextInput value={value} disabled fullWidth />
);
```
#### No Single Variable Prop Spreading in JSX Elements
Avoid using single variable prop spreading in JSX elements, like `{...props}`. This practice often results in code that is less readable and harder to maintain because it's unclear which props the component is receiving.
```tsx
/* ❌ - Bad, spreads a single variable prop into the underlying component
*/
const MyComponent = (props: OwnProps) => {
return <OtherComponent {...props} />;
}
```
```tsx
/* ✅ - Good, Explicitly lists all props
* - Enhances readability and maintainability
*/
const MyComponent = ({ prop1, prop2, prop3 }: MyComponentProps) => {
return <OtherComponent {...{ prop1, prop2, prop3 }} />;
};
```
Rationale:
- At a glance, it's clearer which props the code passes down, making it easier to understand and maintain.
- It helps to prevent tight coupling between components via their props.
- Linting tools make it easier to identify misspelled or unused props when you list props explicitly.
## JavaScript
### Use nullish-coalescing operator `??`
```tsx
// ❌ Bad, can return 'default' even if value is 0 or ''
const value = process.env.MY_VALUE || 'default';
// ✅ Good, will return 'default' only if value is null or undefined
const value = process.env.MY_VALUE ?? 'default';
```
### Use optional chaining `?.`
```tsx
// ❌ Bad
onClick && onClick();
// ✅ Good
onClick?.();
```
## TypeScript
### Use `type` instead of `interface`
Always use `type` instead of `interface`, because they almost always overlap, and `type` is more flexible.
```tsx
// ❌ Bad
interface MyInterface {
name: string;
}
// ✅ Good
type MyType = {
name: string;
};
```
### Use string literals instead of enums
[String literals](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types) are the go-to way to handle enum-like values in TypeScript. They are easier to extend with Pick and Omit, and offer a better developer experience, especially with code completion.
You can see why TypeScript recommends avoiding enums [here](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#enums).
```tsx
// ❌ Bad, utilizes an enum
enum Color {
Red = "red",
Green = "green",
Blue = "blue",
}
let color = Color.Red;
```
```tsx
// ✅ Good, utilizes a string literal
let color: "red" | "green" | "blue" = "red";
```
#### GraphQL and internal libraries
You should use enums that GraphQL codegen generates.
It's also better to use an enum when using an internal library, so the internal library doesn't have to expose a string literal type that is not related to the internal API.
Example:
```TSX
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
```
## Styling
### Use StyledComponents
Style the components with [Linaria styled](https://github.com/callstack/linaria).
```tsx
// ❌ Bad
<div className="my-class">Hello World</div>
```
```tsx
// ✅ Good
const StyledTitle = styled.div`
color: red;
`;
```
Prefix styled components with "Styled" to differentiate them from "real" components.
```tsx
// ❌ Bad
const Title = styled.div`
color: red;
`;
```
```tsx
// ✅ Good
const StyledTitle = styled.div`
color: red;
`;
```
### Theming
Utilizing the theme for the majority of component styling is the preferred approach.
#### Units of measurement
Avoid using `px` or `rem` values directly within the styled components. The necessary values are generally already defined in the theme, so it’s recommended to make use of the theme for these purposes.
#### Colors
Refrain from introducing new colors; instead, use the existing palette from the theme. Should there be a situation where the palette does not align, please leave a comment so that the team can rectify it.
```tsx
// ❌ Bad, directly specifies style values without utilizing the theme
const StyledButton = styled.button`
color: #333333;
font-size: 1rem;
font-weight: 400;
margin-left: 4px;
border-radius: 50px;
`;
```
```tsx
// ✅ Good, utilizes the theme
const StyledButton = styled.button`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.rounded};
`;
```
## Enforcing No-Type Imports
Avoid type imports. To enforce this standard, an Oxlint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.
```tsx
// ❌ Bad
import { type Meta, type StoryObj } from '@storybook/react';
// ❌ Bad
import type { Meta, StoryObj } from '@storybook/react';
// ✅ Good
import { Meta, StoryObj } from '@storybook/react';
```
### Why No-Type Imports
- **Consistency**: By avoiding type imports and using a single approach for both type and value imports, the codebase remains consistent in its module import style.
- **Readability**: No-type imports improve code readability by making it clear when you're importing values or types. This reduces ambiguity and makes it easier to understand the purpose of imported symbols.
- **Maintainability**: It enhances codebase maintainability because developers can identify and locate type-only imports when reviewing or modifying code.
### Oxlint Rule
An Oxlint rule, `typescript/consistent-type-imports`, enforces the no-type import standard. This rule will generate errors or warnings for any type import violations.
Please note that this rule specifically addresses rare edge cases where unintentional type imports occur. TypeScript itself discourages this practice, as mentioned in the [TypeScript 3.8 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html). In most situations, you should not need to use type-only imports.
To ensure your code complies with this rule, make sure to run Oxlint as part of your development workflow.

View File

@@ -0,0 +1,64 @@
---
title: Work with Figma
info: Learn how you can collaborate with Twenty's Figma
---
Figma is a collaborative interface design tool that aids in bridging the communication barrier between designers and developers.
This guide explains how you can collaborate with Figma.
## Access
1. **Access the shared link:** You can access the project's Figma file [here](https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty).
2. **Sign in:** If you're not already signed in, Figma will prompt you to do so.
Key features are only available to logged-in users, such as the developer mode and the ability to select a dedicated frame.
<Warning>
You will not be able to collaborate effectively without an account.
</Warning>
## Figma structure
On the left sidebar, you can access the different pages of Twenty's Figma. This is how they're organized:
- **Components page:** This is the first page. The designer uses it to create and organize the reusable design elements used throughout the design file. For example, buttons, icons, symbols, or any other reusable components. It serves to maintain consistency across the design.
- **Main page:** The second page is the main page, which shows the complete user interface of the project. You can press ***Play*** to use the full app prototype.
- **Features pages:** The other pages are typically dedicated to features in progress. They contain the design of specific features or modules of the application or website. They are typically still in progress.
## Useful Tips
With read-only access, you can't edit the design, but you can access all features that will be useful to convert the designs into code.
### Use the Dev mode
Figma's Dev Mode enhances developers' productivity by providing easy design navigation, effective asset management, efficient communication tools, toolbox integrations, quick code snippets, and key layer information, bridging the gap between design and development. You can learn more about Dev Mode [here](https://www.figma.com/dev-mode/).
Switch to the "Developer" mode in the right part of the toolbar to see design specs, copy CSS, and access assets.
### Use the Prototype
Click on any element on the canvas and press the “Play” button at the top right edge of the interface to access the prototype view. Prototype mode allows you to interact with the design as if it were the final product. It demonstrates the flow between screens and how interface elements like buttons, links, or menus behave when interacted with.
1. **Understanding transitions and animations:** In the Prototype mode, you can view any transitions or animations added by a designer between screens or UI elements, providing clear visual instructions to developers on the intended behavior and style.
2. **Implementation clarification:** A prototype can also help reduce ambiguities. Developers can interact with it to gain a better understanding of the functionality or appearance of particular elements.
For more comprehensive details and guidance on learning the Figma platform, you can visit the official [Figma Documentation](https://help.figma.com/hc/en-us).
### Measure distances
Select an element, hold `Option` key (Mac) or `Alt` key (Windows), then hover over another element to see the distance between them.
### Figma extension for VSCode (Recommended)
[Figma for VS Code](https://marketplace.visualstudio.com/items?itemName=figma.figma-vscode-extension)
lets you navigate and inspect design files, collaborate with designers, track changes, and speed up implementation - all without leaving your text editor.
It's part of our recommended extensions.
## Collaboration
1. **Using Comments:** You are welcome to use the comment feature by clicking on the bubble icon in the left part of the toolbar.
2. **Cursor chat:** A nice feature of Figma is the Cursor chat. Just press `;` on Mac and `/` on Windows to send a message if you see someone else using Figma as the same time as you.

View File

@@ -0,0 +1,314 @@
---
title: Local Setup
icon: "laptop-code"
description: "The guide for contributors (or curious developers) who want to run Twenty locally."
---
## Prerequisites
<Tabs>
<Tab title="Linux and macOS">
Before you can install and use Twenty, make sure you install the following on your computer:
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
- [Node v24.5.0](https://nodejs.org/en/download)
- [yarn v4](https://yarnpkg.com/getting-started/install)
- [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md)
<Warning>
`npm` won't work, you should use `yarn` instead. Yarn is now shipped with Node.js, so you don't need to install it separately.
You only have to run `corepack enable` to enable Yarn if you haven't done it yet.
</Warning>
</Tab>
<Tab title="Windows (WSL)">
1. Install WSL
Open PowerShell as Administrator and run:
```powershell
wsl --install
```
You should now see a prompt to restart your computer. If not, restart it manually.
Upon restart, a PowerShell window will open and install Ubuntu. This may take up some time.
You'll see a prompt to create a username and password for your Ubuntu installation.
2. Install and configure git
```bash
sudo apt-get install git
git config --global user.name "Your Name"
git config --global user.email "youremail@domain.com"
```
3. Install nvm, node.js and yarn
<Warning>
Use `nvm` to install the correct `node` version. The `.nvmrc` ensures all contributors use the same version.
</Warning>
```bash
sudo apt-get install curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
```
Close and reopen your terminal to use nvm. Then run the following commands.
```bash
nvm install # installs recommended node version
nvm use # use recommended node version
corepack enable
```
</Tab>
</Tabs>
---
## Step 1: Git Clone
In your terminal, run the following command.
<Tabs>
<Tab title="SSH (Recommended)">
If you haven't already set up SSH keys, you can learn how to do so [here](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/about-ssh).
```bash
git clone git@github.com:twentyhq/twenty.git
```
</Tab>
<Tab title="HTTPS">
```bash
git clone https://github.com/twentyhq/twenty.git
```
</Tab>
</Tabs>
## Step 2: Position yourself at the root
```bash
cd twenty
```
You should run all commands in the following steps from the root of the project.
## Step 3: Set up a PostgreSQL Database
<Tabs>
<Tab title="Linux">
**Option 1 (preferred):** To provision your database locally:
Use the following link to install PostgreSQL on your Linux machine: [PostgreSQL Installation](https://www.postgresql.org/download/linux/)
```bash
psql postgres -c "CREATE DATABASE \"default\";" -c "CREATE DATABASE test;"
```
Note: You might need to add `sudo -u postgres` to the command before `psql` to avoid permission errors.
**Option 2:** If you have docker installed:
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
<Tab title="Mac OS">
**Option 1 (preferred):** To provision your database locally with `brew`:
```bash
brew install postgresql@16
export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"
brew services start postgresql@16
psql postgres -c "CREATE DATABASE \"default\";" -c "CREATE DATABASE test;"
```
You can verify if the PostgreSQL server is running by executing:
```bash
brew services list
```
The installer might not create the `postgres` user by default when installing
via Homebrew on macOS. Instead, it creates a PostgreSQL role that matches your macOS
username (e.g., "john").
To check and create the `postgres` user if necessary, follow these steps:
```bash
# Connect to PostgreSQL
psql postgres
or
psql -U $(whoami) -d postgres
```
Once at the psql prompt (postgres=#), run:
```bash
# List existing PostgreSQL roles
\du
```
You'll see output similar to:
```bash
Role name | Attributes | Member of
-----------+-------------+-----------
john | Superuser | {}
```
If you do not see a `postgres` role listed, proceed to the next step.
Create the `postgres` role manually:
```bash
CREATE ROLE postgres WITH SUPERUSER LOGIN;
```
This creates a superuser role named `postgres` with login access.
```bash
Role name | Attributes | Member of
-----------+-------------+-----------
postgres | Superuser | {}
john | Superuser | {}
```
**Option 2:** If you have docker installed:
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
<Tab title="Windows (WSL)">
All the following steps are to be run in the WSL terminal (within your virtual machine)
**Option 1:** To provision your PostgreSQL locally:
Use the following link to install PostgreSQL on your Linux virtual machine: [PostgreSQL Installation](https://www.postgresql.org/download/linux/)
```bash
psql postgres -c "CREATE DATABASE \"default\";" -c "CREATE DATABASE test;"
```
Note: You might need to add `sudo -u postgres` to the command before `psql` to avoid permission errors.
**Option 2:** If you have docker installed:
Running Docker on WSL adds an extra layer of complexity.
Only use this option if you are comfortable with the extra steps involved, including turning on [Docker Desktop WSL2](https://docs.docker.com/desktop/wsl).
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
</Tabs>
You can now access the database at `localhost:5432`.
If you used the Docker option above, the default credentials are user `postgres` and password `postgres`. For native PostgreSQL installations, use the credentials and roles configured on your machine.
## Step 4: Set up a Redis Database (cache)
Twenty requires a Redis cache to provide the best performance.
<Tabs>
<Tab title="Linux">
**Option 1:** To provision your Redis locally:
Use the following link to install Redis on your Linux machine: [Redis Installation](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/install-redis-on-linux/)
**Option 2:** If you have docker installed:
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
<Tab title="Mac OS">
**Option 1 (preferred):** To provision your Redis locally with `brew`:
```bash
brew install redis
```
Start your Redis server:
```bash
brew services start redis
```
**Option 2:** If you have docker installed:
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
<Tab title="Windows (WSL)">
**Option 1:** To provision your Redis locally:
Use the following link to install Redis on your Linux virtual machine: [Redis Installation](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/install-redis-on-linux/)
**Option 2:** If you have docker installed:
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
</Tabs>
If you need a client GUI, we recommend [Redis Insight](https://redis.io/insight/) (free version available).
## Step 5: Set up environment variables
Use environment variables or `.env` files to configure your project. More info [here](/developers/self-host/capabilities/setup).
Copy the `.env.example` files in `/front` and `/server`:
```bash
cp ./packages/twenty-front/.env.example ./packages/twenty-front/.env
cp ./packages/twenty-server/.env.example ./packages/twenty-server/.env
```
<Info>
**Multi-Workspace Mode:** By default, Twenty runs in single-workspace mode where only one workspace can be created. To enable multi-workspace support (useful for testing subdomain-based features), set `IS_MULTIWORKSPACE_ENABLED=true` in your server `.env` file. See [Multi-Workspace Mode](/developers/self-host/capabilities/setup#multi-workspace-mode) for details.
</Info>
## Step 6: Installing dependencies
To build Twenty server and seed some data into your database, run the following command:
```bash
yarn
```
Note that `npm` or `pnpm` won't work
## Step 7: Running the project
<Tabs>
<Tab title="Linux">
Depending on your Linux distribution, Redis server might be started automatically.
If not, check the [Redis installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) for your distro.
</Tab>
<Tab title="Mac OS">
Redis should already be running. If not, run:
```bash
brew services start redis
```
</Tab>
<Tab title="Windows (WSL)">
Depending on your Linux distribution, Redis server might be started automatically.
If not, check the [Redis installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) for your distro.
</Tab>
</Tabs>
Set up your database with the following command:
```bash
npx nx database:reset twenty-server
```
Start the server, the worker and the frontend services:
```bash
npx nx start twenty-server
npx nx worker twenty-server
npx nx start twenty-front
```
Alternatively, you can start all services at once:
```bash
npx nx start
```
## Step 8: Use Twenty
**Frontend**
Twenty's frontend will be running at [http://localhost:3001](http://localhost:3001).
You can log in using the default demo account: `tim@apple.dev` (password: `tim@apple.dev`)
**Backend**
- Twenty's server will be up and running at [http://localhost:3000](http://localhost:3000)
- The GraphQL API can be accessed at [http://localhost:3000/graphql](http://localhost:3000/graphql)
- The REST API can be reached at [http://localhost:3000/rest](http://localhost:3000/rest)
## Troubleshooting
If you encounter any problem, check [Troubleshooting](/developers/self-host/capabilities/troubleshooting) for solutions.

View File

@@ -0,0 +1,77 @@
---
title: Commands
icon: "terminal"
description: Useful commands for developing Twenty.
---
Commands can be run from the repository root using `npx nx`. Use `npx nx run {project}:{command}` for explicit targeting.
## Starting the App
```bash
npx nx start twenty-front # Frontend dev server (http://localhost:3001)
npx nx start twenty-server # Backend server (http://localhost:3000)
npx nx run twenty-server:worker # Background worker
```
## Database
```bash
npx nx database:reset twenty-server # Reset and seed database
npx nx run twenty-server:database:migrate:prod # Run migrations
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow> # Generate a migration
```
## Linting
```bash
npx nx lint:diff-with-main twenty-front # Lint changed files (fastest)
npx nx lint:diff-with-main twenty-server
npx nx lint twenty-front --configuration=fix # Auto-fix
```
## Type Checking
```bash
npx nx typecheck twenty-front
npx nx typecheck twenty-server
```
## Testing
```bash
# Frontend
npx nx test twenty-front # Jest unit tests
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:test twenty-front # Storybook tests
# Backend
npx nx run twenty-server:test:unit # Unit tests
npx nx run twenty-server:test:integration # Integration tests
npx nx run twenty-server:test:integration:with-db-reset # Integration with DB reset
# Single file (fastest)
npx jest path/to/test.test.ts --config=packages/{project}/jest.config.mjs
```
## GraphQL
```bash
npx nx run twenty-front:graphql:generate # Regenerate types
npx nx run twenty-front:graphql:generate --configuration=metadata # Metadata schema
```
## Translations
```bash
npx nx run twenty-front:lingui:extract # Extract strings
npx nx run twenty-front:lingui:compile # Compile translations
```
## Build
```bash
npx nx build twenty-shared # Must be built first
npx nx build twenty-front
npx nx build twenty-server
```

View File

@@ -0,0 +1,31 @@
---
title: Contribute
description: Contribute to Twenty's open-source development.
---
<Frame>
<img src="/images/user-guide/github/github-header.png" alt="AI" />
</Frame>
## Overview
Twenty is open-source and welcomes contributions from the community. Whether you're fixing bugs, adding features, or improving documentation, your contributions help make Twenty better for everyone.
## Ways to Contribute
- **Report bugs**: Help identify and document issues
- **Submit features**: Propose and implement new functionality
- **Improve documentation**: Make our docs clearer and more helpful
- **Frontend development**: Work on the React-based UI
- **Backend development**: Contribute to the NestJS server
## Getting Started
<CardGroup cols={2}>
<Card title="Bug Reports & Requests" icon="bug" href="/developers/contribute/capabilities/bug-and-requests">
Report issues or request features
</Card>
<Card title="Frontend Development" icon="browser" href="/developers/contribute/capabilities/frontend-development/folder-architecture-front">
Contribute to the UI
</Card>
</CardGroup>

View File

@@ -0,0 +1,176 @@
---
title: Style Guide
icon: "paintbrush"
description: Code conventions and best practices for contributing to Twenty.
---
## React
### Functional components only
Always use TSX functional components with named exports.
```tsx
// ❌ Bad
const MyComponent = () => {
return <div>Hello World</div>;
};
export default MyComponent;
// ✅ Good
export function MyComponent() {
return <div>Hello World</div>;
};
```
### Props
Create a type named `{ComponentName}Props`. Use destructuring. Don't use `React.FC`.
```tsx
type MyComponentProps = {
name: string;
};
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
```
### No single-variable prop spreading
```tsx
// ❌ Bad
const MyComponent = (props: MyComponentProps) => <Other {...props} />;
// ✅ Good
const MyComponent = ({ prop1, prop2 }: MyComponentProps) => <Other {...{ prop1, prop2 }} />;
```
## State Management
### Jotai atoms for global state
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
key: 'myAtomState',
defaultValue: 'default value',
});
```
- Prefer atoms over prop drilling
- Don't use `useRef` for state — use `useState` or atoms
- Use atom families and selectors for lists
### Avoid unnecessary re-renders
- Extract `useEffect` and data fetching into sibling sidecar components
- Prefer event handlers (`handleClick`, `handleChange`) over `useEffect`
- Don't use `React.memo()` — fix the root cause instead
- Limit `useCallback` / `useMemo` usage
```tsx
// ❌ Bad — useEffect in the same component causes re-renders
export const Page = () => {
const [data, setData] = useAtomState(dataState);
const [dep] = useAtomState(depState);
useEffect(() => { setData(dep); }, [dep]);
return <div>{data}</div>;
};
// ✅ Good — extract into sibling
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [dep] = useAtomState(depState);
useEffect(() => { setData(dep); }, [dep]);
return <></>;
};
export const Page = () => {
const [data] = useAtomState(dataState);
return <div>{data}</div>;
};
```
## TypeScript
- **`type` over `interface`** — more flexible, easier to compose
- **String literals over enums** — except for GraphQL codegen enums and internal library APIs
- **No `any`** — strict TypeScript enforced
- **No type imports** — use regular imports (enforced by Oxlint `typescript/consistent-type-imports`)
- **Use [Zod](https://github.com/colinhacks/zod)** for runtime validation of untyped objects
## JavaScript
```tsx
// Use nullish-coalescing (??) instead of ||
const value = process.env.MY_VALUE ?? 'default';
// Use optional chaining
onClick?.();
```
## Naming
- **Variables**: camelCase, descriptive (`email` not `value`, `fieldMetadata` not `fm`)
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase
- **Files/directories**: kebab-case (`.component.tsx`, `.service.ts`, `.entity.ts`)
- **Event handlers**: `handleClick` (not `onClick` for the handler function)
- **Component props**: prefix with component name (`ButtonProps`)
- **Styled components**: prefix with `Styled` (`StyledTitle`)
## Styling
Use [Linaria](https://github.com/callstack/linaria) styled components. Use theme values — avoid hardcoded `px`, `rem`, or colors.
```tsx
// ❌ Bad
const StyledButton = styled.button`
color: #333333;
font-size: 1rem;
margin-left: 4px;
`;
// ✅ Good
const StyledButton = styled.button`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
```
## Imports
Use aliases instead of relative paths:
```tsx
// ❌ Bad
import { Foo } from '../../../../../testing/decorators/Foo';
// ✅ Good
import { Foo } from '~/testing/decorators/Foo';
import { Bar } from '@/modules/bar/components/Bar';
```
## Folder Structure
```
front
└── modules/ # Feature modules
│ └── module1/
│ ├── components/
│ ├── constants/
│ ├── contexts/
│ ├── graphql/ (fragments, queries, mutations)
│ ├── hooks/
│ ├── states/ (atoms, selectors)
│ ├── types/
│ └── utils/
└── pages/ # Route-level components
└── ui/ # Reusable UI components (display, input, feedback, ...)
```
- Modules can import from other modules, but `ui/` should stay dependency-free
- Use `internal/` subfolders for module-private code
- Components under 300 lines, services under 500 lines

View File

@@ -0,0 +1,55 @@
---
title: APIs
icon: "plug"
description: REST and GraphQL APIs generated from your workspace schema.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
## Schema-per-tenant APIs
There is no static API reference for Twenty. Each workspace has its own schema — when you add a custom object (say `Invoice`), it immediately gets REST and GraphQL endpoints identical to built-in objects like `Company` or `Person`. The API is generated from the schema, so endpoints use your object and field names directly — no opaque IDs.
Your workspace-specific API documentation is available under **Settings → API & Webhooks** after creating an API key. It includes an interactive playground where you can execute real calls against your data.
## Two APIs
**Core API** — `/rest/` and `/graphql/`
CRUD on records: People, Companies, Opportunities, your custom objects. Query, filter, traverse relations.
**Metadata API** — `/rest/metadata/` and `/metadata/`
Schema management: create/modify/delete objects, fields, and relations. This is how you programmatically change your data model.
Both are available as REST and GraphQL. GraphQL adds batch upserts and the ability to traverse relations in a single query. Same underlying data either way.
## Base URLs
| Environment | Base URL |
|-------------|----------|
| Cloud | `https://api.twenty.com/` |
| Self-Hosted | `https://{your-domain}/` |
## Authentication
```
Authorization: Bearer YOUR_API_KEY
```
Create an API key in **Settings → API & Webhooks → + Create key**. Copy it immediately — it's shown once. Keys can be scoped to a specific role under **Settings → Members → Roles → Assignment tab** to limit what they can access.
<VimeoEmbed videoId="928786722" title="Creating API key" />
For OAuth-based access (external apps acting on behalf of users), see [OAuth](/developers/extend/oauth).
## Batch operations
Both REST and GraphQL support batching up to 60 records per request — create, update, or delete. GraphQL also supports batch upsert (create-or-update in one call) using plural names like `CreateCompanies`.
## Rate limits
| Limit | Value |
|-------|-------|
| Requests | 100 per minute |
| Batch size | 60 records per call |

View File

@@ -0,0 +1,127 @@
---
title: Application Config
description: Declare your app's identity, default role, variables, and marketplace metadata with defineApplication.
icon: "rocket"
---
Every app must have exactly one `defineApplication` call. It declares:
- **Identity** — universal identifier, display name, description.
- **Permissions** — which role its logic functions and front components run under.
- **Variables** *(optional)* — key–value pairs exposed to your code as environment variables.
- **Pre-install / post-install / uninstall hooks** *(optional)* — see [Logic Functions](/developers/extend/apps/logic/logic-functions).
- **Billing** *(optional)* — `billing.recurring` for subscription fees the platform raises automatically, `billing.operations` for what your app charges credits for, and `billing.description` for the marketplace; see [Charging Credits](/developers/extend/apps/logic/credits).
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
displayName: 'My Twenty App',
description: 'My first Twenty app',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
label: 'Default recipient name',
description: 'Default recipient name for postcards',
value: 'Jane Doe',
isSecret: false,
},
},
});
```
Notes:
- `universalIdentifier` fields are UUIDs you generate once and own. Keep them stable across syncs. The application's own `universalIdentifier` must be a valid UUID: the server rejects a manifest whose identifier is not one. It is unique across the whole instance and is bound to the workspace that owns its registration, so only that workspace can sync the app — see [Registration ownership](/developers/extend/apps/operations/publishing#registration-ownership).
- `applicationVariables` accept an optional `label` — a user-friendly name displayed in the app's settings page instead of the raw variable key.
- `applicationVariables` become environment variables for your functions and front components. In logic functions (server-side), they are available as `process.env.VARIABLE_NAME`. In front components, use `getApplicationVariable('VARIABLE_NAME')` from `twenty-sdk/front-component`. Variables marked with `isSecret: true` are only injected into logic functions. Front components receive only non-secret variables.
- The default role is detected automatically from the role file marked with [`defineApplicationRole()`](/developers/extend/apps/config/roles) — you do not need to reference it from `defineApplication()`.
- Pre-install, post-install, and uninstall functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`.
- Passing `defaultRoleUniversalIdentifier` explicitly is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`.
- `serverVariables` are instance-scoped configuration and secrets (e.g. API keys). Unlike `applicationVariables`, they declare no value in the manifest — the workspace operator fills them in from the app's settings, and they are injected into logic functions only once set.
- Both kinds of variable accept `isDeprecated: true`. Use it to retire a variable instead of deleting it: keeping the key declared preserves the stored value (deleting it destroys the value the operator entered), and the variable is still injected, so your code can fall back to it — `process.env.NEW_API_KEY ?? process.env.API_KEY`. A deprecated variable disappears from the app's settings once it has no value, and never counts toward the app's configuration check, so `isDeprecated` beats `isRequired`.
- To render a custom configuration UI inside the app's **Settings** tab (in place of the default variable configuration section), declare a front component with [`defineSettingsFrontComponent()`](/developers/extend/apps/layout/front-components#custom-settings-component) in its own file. Only one is allowed per app. System-managed sections (auto-upgrade, App URL, connections) always remain visible.
## Variable types
Both `applicationVariables` and `serverVariables` accept an optional `type` (and, for `SELECT` / `MULTI_SELECT`, an `options` list). Supported types: `TEXT` (default), `BOOLEAN`, `NUMBER`, `NUMERIC`, `DATE`, `DATE_TIME`, `SELECT`, `MULTI_SELECT`, `ARRAY`, `RAW_JSON`, `RICH_TEXT`.
```ts src/application-config.ts
import { defineApplication, FieldType } from 'twenty-sdk/define';
export default defineApplication({
// ...identity, role...
applicationVariables: {
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldType.NUMBER,
value: 10,
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
],
value: 'eu',
},
},
});
```
The `type` only affects **presentation and validation** — it selects the matching input in the workspace settings UI (a toggle, number field, dropdown, date picker, JSON editor, …) and lets the build validate your config (for example, `SELECT` / `MULTI_SELECT` must declare non-empty `options`). It does **not** change how the value reaches your code.
Values are **always injected as strings** — this is inherent to environment variables (`process.env.*` is string-only). When your logic function runs, the executor serializes each value by its declared `type` while building `process.env`, so the string format is consistent no matter how the value was set (manifest default, settings UI, or a previous version):
| Type | `process.env` string |
|------|----------------------|
| `TEXT`, `SELECT`, `DATE`, `DATE_TIME` | the raw value (`"eu"`, `"2026-01-01"`) |
| `BOOLEAN` | `"true"` / `"false"` |
| `NUMBER`, `NUMERIC` | decimal string (`"10"`, `"2.5"`) |
| `MULTI_SELECT`, `ARRAY` | JSON array (`'["email","postcard"]'`) |
| `RAW_JSON`, `RICH_TEXT` | JSON object (`'{"retries":3}'`) |
Parse the string back into the type you expect:
```ts
const maxCards = Number(process.env.MAX_POSTCARDS); // "10" -> 10
const enabled = process.env.ENABLE_TRACKING === 'true'; // "true" -> true
const channels = JSON.parse(process.env.ENABLED_CHANNELS ?? '[]'); // '["email"]' -> ["email"]
const config = JSON.parse(process.env.PROVIDER_CONFIG ?? '{}'); // '{"retries":3}' -> { retries: 3 }
```
The same applies to front components reading values via `getApplicationVariable('VARIABLE_NAME')` — the returned value is a string; parse it as needed.
## Default function role
The role declared with [`defineApplicationRole()`](/developers/extend/apps/config/roles) controls what the app's logic functions and front components can access:
- The runtime tokens injected into your logic functions are derived from this role. A call acting as a person is further narrowed to what that person can do, so it can never exceed either one. See [Whose access a call uses](/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses).
- The typed API client is restricted to the permissions granted to that role.
- Follow least-privilege: declare only the permissions your functions need.
When you scaffold a new app, the CLI creates a starter role file at `src/roles/default-role.ts`. See [Roles & Permissions](/developers/extend/apps/config/roles) for the full reference.
## Marketplace metadata
If you plan to [publish your app](/developers/extend/apps/operations/publishing), these optional fields control how it appears in the marketplace:
| Field | Description |
|-------|-------------|
| `author` | Author or company name |
| `category` | App category for marketplace filtering |
| `logo` | Path to your app logo bundled in `public/` (e.g., `public/logo.png`) |
| `galleryImages` | Array of gallery image paths bundled in `public/` (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. Images do not render, and raw HTML shows as plain text |
| `billing.description` | What the app costs, in one short friendly line shown next to the install button (e.g. `$0.02 per minute recorded`). Set it if your app [charges credits](/developers/extend/apps/logic/credits) |
| `websiteUrl` | Link to your website |
| `termsUrl` | Link to terms of service |
| `emailSupport` | Support email address |
| `issueReportUrl` | Link to issue tracker |
<Note>
`logoUrl` and `screenshots` are deprecated aliases of `logo` and `galleryImages`. External absolute URLs (`http://` or `https://`) are not supported for these fields: they are dropped with a warning at build time. Bundle the images in your app's `public/` folder instead.
</Note>

View File

@@ -0,0 +1,188 @@
---
title: Install Hooks
description: Run logic during the install, upgrade, or uninstall lifecycle — seed data, back up records, validate the upgrade, clean up external resources.
icon: "wrench"
---
Install hooks are special logic functions that run during the install, upgrade, or uninstall lifecycle. They share the same handler runtime as regular [logic functions](/developers/extend/apps/logic/logic-functions), but they're declared with their own define functions and live outside the normal trigger model (HTTP, cron, database events). Install hooks receive an `InstallPayload` (`{ previousVersion?: string; newVersion: string }` — `previousVersion` is `undefined` on a fresh install); the uninstall hook receives an `UninstallPayload` (`{ version?: string }` — the version being removed).
Each app may define **at most one** of each hook (pre-install, post-install, uninstall). The manifest build errors if more than one of any kind is detected.
```
┌─────────────────────────────────────────────────────────────┐
│ install flow │
│ │
│ upload package → [pre-install] → metadata migration → │
│ generate SDK → [post-install] │
│ │
│ old schema visible new schema visible │
└─────────────────────────────────────────────────────────────┘
```
## At a glance
| | `definePreInstallLogicFunction` | `definePostInstallLogicFunction` |
|---|---|---|
| Runs | Before the metadata migration — the **previous** schema and data are still intact | After the migration and SDK generation — the **new** schema is in place |
| Execution | Always synchronous; blocks the install | Async by default (queued, 3 retries); sync opt-in via `shouldRunSynchronously: true` |
| On failure | Install is **aborted** before any schema change | Async: retried up to 3 times. Sync: caller receives `POST_INSTALL_ERROR` (schema changes are **not** rolled back) |
| Typical use | Back up or fix data a migration would lose; refuse a risky upgrade by throwing | Seed default data, configure the workspace, register external resources |
**Rule of thumb:** default to post-install. Only reach for pre-install when the migration itself is destructive and you need to intercept the previous state before it is gone.
| You want to... | Use |
|---|---|
| Seed data, configure the workspace, register external resources | `post-install` |
| Long-running work that shouldn't block the install response | `post-install` (default async mode, with worker retries) |
| Fast setup the caller relies on immediately after the install returns | `post-install` with `shouldRunSynchronously: true` |
| Read or back up data that the upcoming migration would lose | `pre-install` |
| Reject an upgrade that would corrupt existing data | `pre-install` (throw from the handler) |
| Reconciliation on every upgrade | Either hook with `shouldRunOnVersionUpgrade: true` |
## Behavior shared by both hooks
- The config is a `defineLogicFunction` config minus the trigger settings, plus `shouldRunOnVersionUpgrade`.
- **When it runs**: fresh installs only, by default. Set `shouldRunOnVersionUpgrade: true` to also run on upgrades. Use `previousVersion` / `newVersion` to branch on the upgrade path.
- **Idempotency matters**: async post-install may be retried, and either hook re-runs on upgrades when `shouldRunOnVersionUpgrade` is on.
- The usual logic-function environment (`APPLICATION_ID`, `APP_ACCESS_TOKEN`, `API_URL`) is injected, so you can call the Twenty API with your app's token.
- The hook is attached to the application manifest automatically at build time (`preInstallLogicFunction` / `postInstallLogicFunction`) — nothing to reference in [`defineApplication()`](/developers/extend/apps/config/application).
- The default `timeoutSeconds` is 300 to allow longer setup tasks like data seeding.
- **Not executed in dev mode**: `yarn twenty dev` skips the install flow and syncs files directly, so hooks never run there. Trigger them manually instead:
```bash filename="Terminal"
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
```
<AccordionGroup>
<Accordion title="definePostInstallLogicFunction" description="Runs after the workspace metadata migration is applied">
Runs once your app has finished installing: metadata synchronized, SDK client generated, new schema queryable. Example — seed a default record on fresh installs:
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
if (previousVersion) return; // fresh installs only
const client = new CoreApiClient();
await client.mutation({
createPostCard: {
__args: { data: { name: 'Welcome to Postcard', content: 'Your first card!' } },
id: true,
},
});
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
name: 'post-install',
description: 'Seeds a welcome post card after install.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: false,
shouldRunSynchronously: false,
handler,
});
```
The `shouldRunSynchronously` flag controls the execution model:
- `false` *(default)* — enqueued on the message queue (`retryLimit: 3`) and run by a worker. The install response returns as soon as the job is enqueued. **Use for long-running work** — seeding large datasets, slow third-party APIs.
- `true` — executed inline during the install flow. The install request blocks until the handler finishes; a thrown error surfaces as `POST_INSTALL_ERROR` to the caller (no retries). **Use for fast, must-complete-before-response work.** The migration has already been applied at this point, so a failure does not roll back schema changes — it only surfaces the error.
</Accordion>
<Accordion title="definePreInstallLogicFunction" description="Runs before the workspace metadata migration is applied">
Runs before the metadata migration, against the **previous** schema — the right place to back up data a migration would lose, or to refuse a risky upgrade. Before executing, the server runs a purely additive "pared-down sync" that registers only the new version's pre-install function; everything else — the previous version's objects, fields, and data — is untouched when your handler runs.
Pre-install is always **synchronous** and blocks the install. If the handler throws, the install is aborted before any schema change — the workspace stays on the previous version in a consistent state. This is intentional: pre-install is your last chance to refuse a risky upgrade.
Example — copy a legacy field's values before the migration drops it:
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise<void> => {
// Only the 1.x → 2.x upgrade drops the legacy `notes` field.
if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) {
return;
}
const client = new CoreApiClient();
const { postCards } = await client.query({
postCards: {
__args: { filter: { notes: { isNot: null } } },
edges: { node: { id: true, notes: true } },
},
});
// Copy legacy `notes` into `description` before the migration drops the
// column. If this fails, the upgrade aborts and the workspace stays on v1.
for (const { node } of postCards.edges) {
await client.mutation({
updatePostCard: {
__args: { id: node.id, data: { description: node.notes } },
id: true,
},
});
}
};
export default definePreInstallLogicFunction({
universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
name: 'pre-install',
description: 'Backs up legacy notes into description before the v2 migration.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: true,
handler,
});
```
</Accordion>
</AccordionGroup>
## Uninstall hook
`defineUninstallLogicFunction` declares a hook that runs when a user uninstalls your app. It executes **before** the app's metadata, data, and code are removed — once the deletion migration runs there is nothing left to execute — so your handler can still query the app's objects and records. Use it for cleanup of external resources: deprovision API resources, delete remaining bots, revoke webhooks.
Notes:
- The hook is best-effort: it runs synchronously, but a failure is logged and **never blocks the uninstall** — cleanup must not make an app impossible to remove.
- It receives `UninstallPayload` (`{ version?: string }` — the version being removed).
- It does **not** run when a failed fresh install is rolled back — the app never finished installing.
- The hook cannot run after the app is gone, so external cleanup that depends on app data (e.g. bot IDs stored in records) belongs here, not in an external scheduled job.
- Like the install hooks, it is **not executed in dev mode** — trigger it manually instead:
```bash filename="Terminal"
yarn twenty dev:function:exec --uninstall
```
```ts src/logic-functions/uninstall.ts
import { defineUninstallLogicFunction, type UninstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: UninstallPayload): Promise<void> => {
const client = new CoreApiClient();
const { meetingBots } = await client.query({
meetingBots: { edges: { node: { id: true, externalBotId: true } } },
});
// Delete the provider-side bots so nothing keeps recording after uninstall.
for (const { node } of meetingBots.edges) {
await fetch(`https://api.recorder.example/bots/${node.externalBotId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.RECORDER_API_KEY}` },
});
}
};
export default defineUninstallLogicFunction({
universalIdentifier: 'b2c3d4e5-6789-01bc-def0-234567890abc',
name: 'uninstall',
description: 'Deletes remaining recorder bots when the app is uninstalled.',
timeoutSeconds: 300,
handler,
});
```

View File

@@ -0,0 +1,56 @@
---
title: Overview
description: Configure the app itself — its identity, default permissions, and what runs at install time.
icon: "screwdriver-wrench"
---
A Twenty app's **config layer** is what describes the app *to the platform* — its identity, the permissions it holds, and the code that runs during install or upgrade. These declarations don't add new data shapes or runtime behavior; they tell Twenty *who the app is* and *how to set it up*.
```text
┌────────────────────────────────────────────────────────┐
│ Application — identity, default role, variables, │
│ marketplace metadata │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Role — what the app's logic functions can read │ │
│ │ and write (referenced by Application) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
│
▼ (at install / upgrade time)
┌──────────────────────────────────┐
│ Pre-install hook │ before metadata migration
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Post-install hook │ after metadata migration
└──────────────────────────────────┘
│
▼ (at uninstall time)
┌──────────────────────────────────┐
│ Uninstall hook │ before app removal
└──────────────────────────────────┘
```
## In this section
<CardGroup cols={2}>
<Card title="Application Config" icon="rocket" href="/developers/extend/apps/config/application">
`defineApplication` — identity, default role, variables, marketplace metadata.
</Card>
<Card title="Roles & Permissions" icon="shield-halved" href="/developers/extend/apps/config/roles">
`defineRole` — declare what your app's logic functions can read and write.
</Card>
<Card title="Install Hooks" icon="wrench" href="/developers/extend/apps/config/install-hooks">
`definePreInstallLogicFunction`, `definePostInstallLogicFunction`, and `defineUninstallLogicFunction` — back up data, seed defaults, validate upgrades, clean up on removal.
</Card>
</CardGroup>
## How the pieces relate
- **Application** is the entry point. Every app has exactly one `defineApplication()` call, and it points at one **Role** as its default.
- The **Role** controls what the app's logic functions and front components can read and write. Follow least-privilege: only grant the permissions your code actually needs.
- **Install Hooks** run during install or upgrade — pre-install before the metadata migration (so it can refuse a risky upgrade), post-install after the migration (so it can seed default data against the new schema). The uninstall hook runs right before the app is removed, so it can clean up external resources while the app's data is still readable.
<Note>
Install hooks share the [logic function](/developers/extend/apps/logic/logic-functions) runtime — same handler signature, same environment variables, same typed API client — but they're declared with their own define functions and live outside the regular trigger model (HTTP, cron, database events).
</Note>

View File

@@ -0,0 +1,67 @@
---
title: Public Assets
description: Ship static files — images, icons, fonts — alongside your app via the public/ folder.
icon: "folder-open"
---
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 `logo` and `galleryImages` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published. External absolute URLs are ignored for these fields — bundle the images in `public/` instead. Each image must not exceed 10 MB.
- **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 dev: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 } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
const invoiceUrl = getPublicAssetUrl('templates/invoice.png');
// Fetch the file content (no auth required — public endpoint)
const response = await fetch(invoiceUrl);
const buffer = await response.arrayBuffer();
return { logoUrl, size: buffer.byteLength };
};
export default defineLogicFunction({
universalIdentifier: 'a1b2c3d4-...',
name: 'send-invoice',
description: 'Sends an invoice with the app logo',
timeoutSeconds: 10,
handler,
});
```
**In a front component:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
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.

View File

@@ -0,0 +1,223 @@
---
title: Roles & Permissions
description: Declare what objects and fields your app's logic functions and front components can read and write.
icon: 'shield-halved'
---
A **role** is a permission set: which objects an app can read or write, which fields it can see, and which platform-level capabilities it can use. Every app's logic functions and front components inherit the permissions of the role marked with `defineApplicationRole()` (see [The default function role](#the-default-function-role) below).
```ts src/roles/restricted-company-role.ts
import {
defineRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
} from 'twenty-sdk/define';
export default defineRole({
universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6',
label: 'My new role',
description: 'A role that can be used in your workspace',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
```
## Row-level security
Row-level permissions require the **Organization plan or above** — see
[pricing](https://twenty.com/pricing). What happens on a plan without the entitlement is covered in
the notes at the end of this section.
Object and field permissions decide _which objects and fields_ a role can touch. **Row-level
permission predicates** go further and decide _which records_ a role can see and act on — for
example, a self-service role where each external user sees only their own records.
Declare predicates with `rowLevelPermissionPredicates` on the role. Like the rest of the manifest,
each predicate carries its own `universalIdentifier`, and references an object and a field by their
`universalIdentifier`, an `operand`, and (optionally) a workspaceMember field whose value is injected
at query time — so you can express "the record's owner relation **is** the current workspace member":
```ts src/roles/partner-role.ts
import {
defineRole,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/account-owner.field';
export default defineRole({
universalIdentifier: 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343',
label: 'Partner',
description: 'External partner — sees only its own records',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd0f0c1a2-3b4c-4d5e-8f60-111111111111',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier: ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier,
},
],
});
```
Because predicates ship in the manifest, they are created, updated, and removed together with the
role on every install and upgrade — there is no separate post-install step to keep in sync.
### Combining predicates with groups
By default a role's predicates are combined with `AND`. To combine some of them with `OR` (or to
nest logic), declare a `rowLevelPermissionPredicateGroups` entry and point each predicate at it via
`predicateGroupUniversalIdentifier`. This role lets a partner see an Opportunity it either **owns**
or is the **point of contact** for:
```ts src/roles/partner-opportunities-role.ts
import {
defineRole,
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
const OPPORTUNITY = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity;
const CURRENT_MEMBER =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier;
export default defineRole({
universalIdentifier: 'b2a1c0d9-8e7f-4a6b-9c5d-222222222222',
label: 'Partner (opportunities)',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd4c3b2a1-0e9f-4c8b-9e7d-444444444444',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY.fields.owner.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
{
universalIdentifier: 'e5d4c3b2-1f0e-4d9c-8f8e-555555555555',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier:
OPPORTUNITY.fields.pointOfContact.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
],
});
```
Notes:
- Give every predicate and group a stable `universalIdentifier` (any uuid) — it keys the entity
across upgrades, and predicates reference groups by it.
- Predicates can reference objects and fields owned by your app or by Twenty's standard objects.
- Row-level security is enforced for workspaces on the **Organization plan or above**. On other
plans the predicates declared in an app manifest still sync, they are simply not enforced.
- The Metadata API behaves differently from the manifest here: without the entitlement,
`upsertRowLevelPermissionPredicates` is rejected with
`ROW_LEVEL_PERMISSION_FEATURE_DISABLED` rather than syncing. A workspace on a plan that does not
include row-level security therefore cannot create predicates outside of an app manifest, even
though the `rowLevelPermissionPredicates` field is present on the `Role` type when you introspect
the schema.
## The default function role
When you scaffold a new app, the CLI creates a default role file declared with `defineApplicationRole()`:
```ts src/roles/default-role.ts
import { defineApplicationRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [],
});
```
`defineApplicationRole()` is a thin wrapper around `defineRole()` that flags **the** role used as your application's default at install time. Validation is identical to `defineRole`, but the build pipeline auto-wires its `universalIdentifier` into the application manifest's `defaultRoleUniversalIdentifier` — so you do not need to reference it from [`defineApplication`](/developers/extend/apps/config/application) yourself.
Notes:
- Exactly **one** `defineApplicationRole(...)` is allowed per app — the manifest build will fail if it finds more than one.
- Use `defineRole()` (not `defineApplicationRole()`) for any **additional** roles your app ships.
- Setting `defaultRoleUniversalIdentifier` explicitly on `defineApplication()` is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`.
## Best practices
- Start from the scaffolded role, then progressively restrict it — the default grants broad read access, which is rarely what you want in production.
- Replace `objectPermissions` and `fieldPermissions` with the exact objects and fields your functions actually need.
- `permissionFlagUniversalIdentifiers` control access to platform-level capabilities. Keep them minimal.
- A permission flag your app declares with `definePermissionFlag()` carries a `permissionType`: `'tool'` (the default) for a capability an agent or a user can be granted, `'settings'` for a flag that unlocks a settings area.
- See a working example: [`examples/hello-world/src/roles/default-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/hello-world/src/roles/default-role.ts).

View File

@@ -0,0 +1,51 @@
---
title: Extending Objects
description: Add fields to standard Twenty objects (Person, Company, …) or to objects from other apps using defineField.
icon: "wand-magic-sparkles"
---
Use `defineField()` to add a field to an object you don't own — a standard Twenty object like Person or Company, or an object shipped by another installed app. Unlike inline fields declared inside [`defineObject`](/developers/extend/apps/data/objects), 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/define';
export default defineField({
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
name: 'loyaltyTier',
type: FieldType.SELECT,
label: 'Loyalty Tier',
icon: 'IconStar',
options: [
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
],
});
```
## Key points
- `objectUniversalIdentifier` identifies the target object. For standard Twenty objects, import the constant from `twenty-sdk`:
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier
// …
```
- When defining fields **inline inside `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()`.
- `SELECT` and `MULTI_SELECT` follow the same [option validation and color rules](/developers/extend/apps/data/objects#select-options) as inline fields. An omitted or null color defaults to `gray`; unsupported colors are rejected.
- File location is up to you. The convention is `src/fields/<name>.field.ts`, but the SDK detects fields anywhere in `src/`.
- `writability` can also be set per field: `MetadataWritability.APPLICATION` restricts writes to your app's own logic functions, `MetadataWritability.SYSTEM` is reserved for platform-managed data. A field's writability can only be stricter than its object's level, and reads are unaffected. See [Objects](/developers/extend/apps/data/objects) for the full semantics.
- `isSearchable: true` can be set on `defineField()` with the same constraints as inline fields (searchable target object, text-compatible field type), so records of the target object become findable through your field's values in full-text search.
- `isAuditLogged: false` can be set on `defineField()` to keep the field's changes out of the target record's timeline, which matters most for fields your app rewrites on a schedule.
- To add a tab to a standard page layout (e.g. the Task or Company detail page), use [`definePageLayoutTab`](/developers/extend/apps/layout/page-layouts#definepagelayouttab) with `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` from `twenty-sdk/define`.
## Adding a relation to an existing object
To add a relation field (e.g. linking your custom object to a standard `Person`), use `defineField()` with `FieldType.RELATION`. The pattern is the same as for inline relations but with `objectUniversalIdentifier` set explicitly. See [Relations](/developers/extend/apps/data/relations) for the bidirectional pattern.

View File

@@ -0,0 +1,164 @@
---
title: Objects
description: Declare new record types — custom tables with their own fields — using defineObject.
icon: "table"
---
Custom **objects** are new record types your app adds to a workspace — Post Card, Invoice, Subscription, anything specific to your domain. Each object declares its schema (fields, relations, default values) and a stable universal identifier that survives across syncs and deploys.
```ts src/objects/post-card.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
description: 'A post card object',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
name: 'content',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
name: 'recipientName',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
name: 'recipientAddress',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
name: 'status',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
{ value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
{ value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
name: 'deliveredAt',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
```
## Key points
- 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.
- `openRecordIn` sets where records of this object open when clicked: `ObjectOpenRecordIn.USER_CHOICE` (the default, following each workspace member's own preference from Settings → Experience), `ObjectOpenRecordIn.SIDE_PANEL`, or `ObjectOpenRecordIn.RECORD_PAGE`. Pin it to `RECORD_PAGE` for records that need a full page to be usable, the way workflows and dashboards do, or to `SIDE_PANEL` for records that only make sense as a quick panel, the way calendar events do.
- `writability` controls who may write records of the object at all, before role permissions apply: `MetadataWritability.OPEN` (the default — workspace roles decide), `MetadataWritability.APPLICATION` (only your app's own logic functions can create, update, or delete records; use this for configuration-like objects whose records grant behavior, so workspace members with broad record access cannot edit them through the API), or `MetadataWritability.SYSTEM` (reserved for platform-managed data). Reads are unaffected — this is enforced server-side, unlike `isUIEditable`, which only hides UI affordances. It also exists per field, where it can only be stricter than the object's level.
- `readability` declares who may read records of the object: `MetadataReadability.OPEN` (the default — workspace roles decide), `MetadataReadability.PRIVATE` (only principals the record was shared with), `MetadataReadability.INHERITED` (whoever reads the parent record), `MetadataReadability.APPLICATION` (only your app's own logic functions) or `MetadataReadability.SYSTEM` (platform-managed data). `SYSTEM` is always enforced. `PRIVATE`, `INHERITED` and `APPLICATION` are enforced on workspaces where record sharing is enabled (`IS_RECORD_SHARING_ENABLED`); elsewhere they still behave like `OPEN`.
- `readabilityParentFieldUniversalIdentifiers` goes with `MetadataReadability.INHERITED`: the universal identifiers of the relation fields that lead to the parent records.
- A many-to-one field names the record the row points to. Declaring one field of a morph relation covers every target of that relation.
- A one-to-many field names a child object whose rows point back at the record, such as the join rows of a many-to-many relation. The record is then readable when at least one of those rows leads to a readable record: a note follows the people, companies and opportunities its note targets attach it to.
- Several parents combine as a union: one readable parent is enough.
- A parent grants access under its complete policy, the one a query of the parent applies: the reader's permission on the parent object, the row-level restrictions of their role and the parent's own readability. A record never shows through a parent its reader could not query. Subscriptions, webhooks, workflows and logic functions decide what an event of an `INHERITED` record reaches by that same policy.
- The share rows on the record itself grant access exactly as on a `PRIVATE` record. Its creator gets one when the record is created while record sharing is enabled, so whoever created a record keeps seeing it, and a record whose parent fields are all empty is visible to those it was shared with directly.
- An `INHERITED` object that names no usable parent field behaves like a `PRIVATE` one.
- `color` sets the accent color of the object in the UI. Omit it and Twenty picks one for the object.
- `imageIdentifierFieldMetadataUniversalIdentifier` names the field whose value is shown as the record avatar. Omit it to let Twenty pick the default.
- `isLabelSyncedWithName`, on an object or on a field, keeps the API name in sync with the label when the label is edited in Settings. It defaults to `false`, so a renamed label leaves the name untouched.
- `isSearchable: true`, on a field, includes its values in the object's full-text search (global search and the command menu). It requires the object itself to be searchable and a text-compatible field type. An omitted value means not searchable, except for the object's label identifier field, which is always searchable and cannot be opted out. Each sync enforces the declared state, so a field toggled searchable from Settings reverts on your app's next sync unless the manifest declares it.
- `isAuditLogged: false`, on a field, keeps its changes out of the record timeline. It defaults to `true`, except on a `POSITION` field, whose changes render blank in the timeline and are never logged. Set it on fields your app rewrites on a schedule, such as a last-contact timestamp or a rollup relation, so each sync does not add an activity row to every record it touches. An update whose whole diff is made of non audit logged fields produces no timeline activity at all. Like `isSearchable`, each sync enforces the declared state.
- Inline fields defined here do **not** need an `objectUniversalIdentifier` — it's inherited from the parent object. Use [`defineField()`](/developers/extend/apps/data/extending-objects) to add fields to objects you don't own.
- You can scaffold new objects with `yarn twenty dev:add object`, which guides you through naming, fields, and relationships. See [Architecture → Scaffolding entities](/developers/extend/apps/getting-started/scaffolding).
<Note>
**Base fields are added automatically.** When you define a custom object, Twenty creates standard fields like `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and `deletedAt` for you. You don't need to declare them in your `fields` array — only your custom fields. You can override a default field by declaring one with the same name, but this is rarely a good idea.
</Note>
## Field types
The full set of `FieldType` values, exported from `twenty-sdk/define`:
| Category | Types |
|----------|-------|
| Text | `TEXT`, `RICH_TEXT`, `ARRAY` (of strings), `RAW_JSON` |
| Numeric | `NUMBER` (`universalSettings.dataType`: `'float'` / `'int'` / `'bigint'`), `NUMERIC` (arbitrary precision), `RATING`, `POSITION` |
| Dates | `DATE`, `DATE_TIME` |
| Choice | `BOOLEAN`, `SELECT`, `MULTI_SELECT` |
| Composite | `FULL_NAME`, `ADDRESS`, `EMAILS`, `PHONES`, `LINKS` (`universalSettings.type`: `'url'` (default) / `'domain'` — a domain field stores the bare host, so `https://www.acme.com/careers` is written as `acme.com`, and a value that is not a host is rejected), `CURRENCY`, `ACTOR`, `FILES` |
| Identifiers & relations | `UUID`, `RELATION`, `MORPH_RELATION` (see [Relations](/developers/extend/apps/data/relations)) |
| System | `TS_VECTOR` (full-text search vector, managed by the server) |
Composite types store multiple sub-fields (e.g. `FULL_NAME` = first + last name; `CURRENCY` = `amountMicros` + `currencyCode`). `SELECT` and `MULTI_SELECT` require an `options` array as in the example above.
### Select options
For `SELECT` and `MULTI_SELECT`, provide a non-empty `options` array of objects. Each option has a `value`, `label`, and `position`. Its `color` is optional and defaults to `gray` when omitted or null.
When provided, `color` must be one of: `red`, `ruby`, `crimson`, `tomato`, `orange`, `amber`, `yellow`, `lime`, `grass`, `green`, `jade`, `mint`, `turquoise`, `cyan`, `sky`, `blue`, `iris`, `violet`, `purple`, `plum`, `pink`, `bronze`, `gold`, `brown`, `gray`.
Both `defineObject()` and `defineField()` return validation errors for unsupported colors or option entries that are null, primitives, or arrays.
## Default values
Literal string defaults must be wrapped in single quotes **inside** the string — `defaultValue: "'Draft'"`, not `defaultValue: "Draft"`. That's why the `status` field above uses `` `'${PostCardStatus.DRAFT}'` ``.
Unquoted strings are reserved for computed defaults, evaluated when a record is created:
- `'uuid'` — generates a UUID (for `UUID` fields)
- `'now'` — the current timestamp (for `DATE_TIME` fields)
The same convention applies to string sub-fields of composite defaults (e.g. `{ source: "'MANUAL'" }` on an `ACTOR` field) and to `SELECT`/`MULTI_SELECT` values. A literal string default left unquoted raises a warning when your app is built.
## Nullability
`isNullable` controls whether a field accepts `NULL`. It defaults to `true` — omit it for optional fields. Set `isNullable: false` to make a field required at the database level.
Changes to `isNullable` are applied on every sync, including syncs that update an existing field — so you can flip a field's nullability by editing the manifest and re-syncing.
<Note>
**Making an existing field non-nullable requires a default value.** When you change a field to `isNullable: false`, you must also provide a non-null `defaultValue`. The default backfills any existing `NULL` rows before the `NOT NULL` constraint is applied; without it the sync fails with `Default value cannot be null for non-nullable fields`. Relation fields and `TS_VECTOR` fields are always nullable, so `isNullable` has no effect on them.
</Note>
```ts
{
universalIdentifier: 'b1a7c0de-1234-4f00-9abc-000000000000',
name: 'reference',
type: FieldType.TEXT,
label: 'Reference',
isNullable: false,
defaultValue: "'N/A'",
}
```
## What's next
- **Connect this object to others** — see [Relations](/developers/extend/apps/data/relations) for the bidirectional relation pattern.
- **Add fields to objects from other apps** — see [Extending Objects](/developers/extend/apps/data/extending-objects) for `defineField()`.
- **Display this object in the UI** — see [Navigation Menu Items](/developers/extend/apps/layout/navigation-menu-items) to add a sidebar entry; see [Views](/developers/extend/apps/layout/views) to add custom list configurations.

View File

@@ -0,0 +1,101 @@
---
title: Overview
description: Shape the data your app adds to a workspace — objects, fields, and relations.
icon: "database"
---
A Twenty app's **data layer** is the data your app *adds* to a workspace — the new record types it declares, the columns it adds to existing objects, and how those records connect to each other.
```text
┌──────────────────────────────────────────────────┐
│ Object — a record type, e.g. PostCard │
│ ├─ Field (name, type, label) │
│ ├─ Field │
│ └─ Relation (link to another object) │
└──────────────────────────────────────────────────┘
│
├── lives in your app, OR
│
▼
┌──────────────────────────────────────────────────┐
│ Standard / other apps' objects │
│ └─ Field added by your app via defineField │
└──────────────────────────────────────────────────┘
```
## In this section
<CardGroup cols={2}>
<Card title="Objects" icon="table" href="/developers/extend/apps/data/objects">
`defineObject` — declare new record types with their own fields.
</Card>
<Card title="Extending Objects" icon="wand-magic-sparkles" href="/developers/extend/apps/data/extending-objects">
`defineField` — add fields to standard or other apps' objects.
</Card>
<Card title="Relations" icon="diagram-project" href="/developers/extend/apps/data/relations">
Bidirectional `MANY_TO_ONE` / `ONE_TO_MANY` connections between objects.
</Card>
<Card title="Timeline Activity Types" icon="clock-rotate-left" href="/developers/extend/apps/data/timeline-activity-types">
Define automatic audit events and explicit app events for record timelines.
</Card>
</CardGroup>
## Entities at a glance
| Entity | Purpose | Defined with |
|--------|---------|--------------|
| **Object** | A new custom record type (e.g. PostCard, Invoice) with its own fields | `defineObject()` |
| **Field** | A column on an object. Standalone fields can extend objects you didn't create (e.g. add `loyaltyTier` to Company) | `defineField()` |
| **Relation** | A bidirectional link between two objects — both sides declared as fields | `defineField()` with `FieldType.RELATION` |
| **Index** | A database index to speed up a recurring query on one of your objects | `defineIndex()` |
| **Timeline activity type** | An automatic audit event or explicit domain event shown in record timelines | `defineTimelineActivityType()` |
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/`, `src/fields/`, `src/indexes/`, and `src/timeline-activity-types/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
## Indexes (optional)
Apps can ship indexes alongside their objects to keep recurring queries fast. The most common case is a status or foreign-key column that you read frequently.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Unique indexes
`defineIndex` accepts `isUnique: true` for both single- and multi-column uniqueness. This is the recommended primitive — `defineField({ isUnique: true })` is deprecated and will be removed in a future release.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Other constraints
- Partial `WHERE` clauses stay under admin control — apps can't declare them.
- Each object is capped at 10 custom indexes (the framework's own indexes don't count).
Order the `fields` array the way Postgres should use it — leftmost column first, like a phone book. Indexes are not free: every write to the table updates them. Add one only when you have a query that needs it.
<Note>
Looking for **Application Config** or **Roles & Permissions**? Those describe the app itself rather than the data it adds — they live under [Config](/developers/extend/apps/config/overview). Looking for **Connections** (Linear, GitHub, Slack OAuth)? Those exist to be called *from* logic functions and live under [Logic](/developers/extend/apps/logic/connections).
</Note>

View File

@@ -0,0 +1,184 @@
---
title: Relations
description: Connect objects together with bidirectional MANY_TO_ONE / ONE_TO_MANY relations.
icon: "diagram-project"
---
Relations connect two objects together. In Twenty, relations are always **bidirectional** — every relation has two sides, and each side is declared as a field that references the other.
| 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 (the 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
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/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
// Import from the other side
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
icon: 'IconUsers',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
```
**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/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
// Import from the other side
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
export default defineField({
universalIdentifier: POST_CARD_FIELD_ID,
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
icon: 'IconMail',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
});
```
<Note>
**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. The build system resolves these at compile time.
</Note>
## Junction relations
A junction object connects one source record to one or more target records. For example, `PostCardRecipient` can hold both a `postCard` relation and a `recipient` relation. On the source object's `ONE_TO_MANY` field, set `junctionTargetFieldUniversalIdentifier` to the relation field on the junction object that points to the target:
```ts
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
relationTargetObjectMetadataUniversalIdentifier:
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldUniversalIdentifier: RECIPIENT_FIELD_ID,
},
});
```
`RECIPIENT_FIELD_ID` must identify a `MANY_TO_ONE` or morph relation on the junction object. It tells Twenty which related record is the target when a feature such as [timeline activity fan-out](/developers/extend/apps/data/timeline-activity-types#automatic-and-explicit-events) traverses the junction.
## 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/define';
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
export default defineField({
universalIdentifier: PERSON_FIELD_ID,
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
});
```
## 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`) |
| `universalSettings.junctionTargetFieldUniversalIdentifier` | Junction ONE_TO_MANY only | Relation field on the junction object that points to the fan-out target |
## Inline relation fields
You can also declare a relation directly inside [`defineObject`](/developers/extend/apps/data/objects). When inline, 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
],
});
```

View File

@@ -0,0 +1,214 @@
---
title: Targeting System Metadata
description: Resolve the deterministic universal identifiers of the metadata Twenty provisions automatically on every object, so your app can reference it without hardcoding.
icon: "gears"
---
Every object in Twenty comes with **system metadata** you never declare yourself, such as a set of fields and a main list view with its columns. The server creates all of it when the object is provisioned, and the set grows as Twenty does.
Because you don't declare it, there's no `universalIdentifier` constant for you to import. Instead, the server **derives** each identifier deterministically, and `twenty-sdk` exposes the same derivation so your manifest can resolve the exact value the server uses.
## System fields
The scalar fields present on every object, none of which you declare with [`defineField()`](/developers/extend/apps/data/extending-objects):
`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`
So how do you reference `createdAt` as a column in a [view](/developers/extend/apps/layout/views)?
### The problem
Since Twenty 2.19, a system field's universal identifier is **derived deterministically** by the server from three inputs: the application universal identifier, the object universal identifier, and the field name. Inventing an id and hardcoding it won't work: it matches nothing on the server, and the sync rejects the dangling reference:
```
Dev sync failed: viewField: INVALID_VIEW_DATA: Field metadata not found
```
### The solution
<Note>
`getFieldUniversalIdentifier` is available from `twenty-sdk` 2.21 onward.
</Note>
Use `getFieldUniversalIdentifier` to resolve the exact same value the server uses. It takes the three inputs and returns the field's universal identifier:
```ts
import { getFieldUniversalIdentifier } from 'twenty-sdk/define';
const createdAtFieldId = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
});
```
- `applicationUniversalIdentifier` is your app's identifier, the one you pass to [`defineApplication()`](/developers/extend/apps/config/application).
- `objectUniversalIdentifier` is the identifier of the object the field belongs to.
- `name` is the system field name, one of the values listed above.
### Example: a createdAt column in a view
The typical case is adding a `createdAt` column to a view of one of your custom objects. Resolve the field id and reference it as any other `fieldMetadataUniversalIdentifier`:
```ts src/views/example-view.ts
import {
defineView,
getFieldUniversalIdentifier,
} from 'twenty-sdk/define';
const APPLICATION_UNIVERSAL_IDENTIFIER =
'0b04e15c-27b2-4741-9046-b32e07469072';
const MY_OBJECT_UNIVERSAL_IDENTIFIER =
'c782b61c-70fd-4c88-9cd6-4e61ab8d7591';
export default defineView({
universalIdentifier: '70f10d44-144a-4da8-8c6f-3ec2422138c0',
name: 'All records',
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconTable',
position: 0,
fields: [
{
universalIdentifier: '75a90bc4-d901-4df4-85e0-af29db5e0104',
fieldMetadataUniversalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
}),
position: 0,
isVisible: true,
size: 200,
},
],
});
```
The same resolved id works anywhere a `fieldMetadataUniversalIdentifier` is expected: view fields, filters, sorts, groups, and page-layout widgets.
<Note>
Resolve the id, don't hardcode it. Because the server derives the value from
the application id, the object id and the field name, calling
`getFieldUniversalIdentifier` keeps your reference correct even if those
inputs change, and avoids drift if the derivation ever evolves.
</Note>
### System relation fields
<Note>
`getSystemRelationFieldUniversalIdentifier` is available from `twenty-sdk`
2.23 onward and requires a Twenty server on 2.23 or later.
</Note>
Besides the scalar system fields above, the server also provisions four **system relation fields** on every object: `timelineActivities`, `attachments`, `noteTargets` and `taskTargets`, each pointing at the matching standard relation object.
These fields are not resolved with `getFieldUniversalIdentifier`: their identifier is derived **name-free**, from the object hosting the field and the object the field points to. That way renaming an object never changes the identifiers of its relation fields.
Use `getSystemRelationFieldUniversalIdentifier` to resolve them:
```ts
import {
getSystemRelationFieldUniversalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
// rocket.attachments — the relation field hosted on your custom object
const rocketAttachmentsFieldId = getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
});
```
- `objectUniversalIdentifier` is the object **hosting** the field.
- `relationTargetObjectUniversalIdentifier` is the object the field **points to**.
The direction is encoded by the argument order. To resolve the reverse side (e.g. `attachment.targetRocket`, the morph field the server creates on the standard relation object), swap the two:
```ts
// attachment.targetRocket — the reverse morph field on Attachment
const attachmentTargetRocketFieldId =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
relationTargetObjectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
});
```
As with scalar system fields, the resolved id works anywhere a `fieldMetadataUniversalIdentifier` is expected.
## System views
<Note>
`getSystemViewUniversalIdentifier` and `getSystemViewFieldUniversalIdentifier`
are available from `twenty-sdk` 2.26 onward and require a Twenty server on
2.26 or later.
</Note>
The server also provisions a **system view** on every object: the main list view (`All {objectLabelPlural}`, keyed on `ViewKey.INDEX` (derived with `SYSTEM_VIEW_KEYS.INDEX`)), with one column per displayable field. Like system relation fields, their identifiers are derived **name-free**, so renaming an object or a field never changes them.
Use `getSystemViewUniversalIdentifier` to resolve the view:
```ts
import {
getSystemViewUniversalIdentifier,
SYSTEM_VIEW_KEYS,
} from 'twenty-sdk/define';
const rocketIndexViewId = getSystemViewUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
viewKey: SYSTEM_VIEW_KEYS.INDEX,
});
```
- `objectMetadataApplicationUniversalIdentifier` is the application owning the **object**, which is what the view is namespaced by.
- `objectUniversalIdentifier` is the object the view lists.
- `viewKey` is the system view discriminator: `SYSTEM_VIEW_KEYS.INDEX` for the main list view, `SYSTEM_VIEW_KEYS.FIELDS_WIDGET` for the record-page fields widget view. It keys the derivation; only `INDEX` is also stored on the view row.
The resolved id works anywhere a `viewUniversalIdentifier` is expected, such as a [`NavigationMenuItemType.VIEW`](/developers/extend/apps/layout/navigation-menu-items) sidebar entry. To simply open an object's main list, prefer `NavigationMenuItemType.OBJECT` with `targetObjectUniversalIdentifier`: it needs no derivation.
`getSystemViewFieldUniversalIdentifier` resolves a single **column** on a system view, from the view and the field it displays:
```ts
import { getSystemViewFieldUniversalIdentifier } from 'twenty-sdk/define';
const rocketNameColumnId = getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: rocketIndexViewId,
fieldMetadataUniversalIdentifier: ROCKET_NAME_FIELD_UNIVERSAL_IDENTIFIER,
});
```
Note the first argument: a column is namespaced by the application owning the **field it displays**, not the one owning the view. A field your app adds to a standard object gets its column derived under your application, on a view owned by Twenty.
<Warning>
System views and their columns are **server-owned**: resolve their identifiers
to reference them, never to declare them. `key` on
[`defineView()`](/developers/extend/apps/layout/views) is deprecated and
ignored, so a manifest view can never claim the `INDEX` key, and the server
already provisions a column for every field you add, so declaring your own
`defineViewField()` for that same field on a system view conflicts with it.
</Warning>
## Standard Twenty objects
For a **standard** Twenty object (Person, Company, Opportunity, …), you don't need to derive anything: the identifiers are pre-computed constants you can import directly, for both fields and views.
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.createdAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.fields.updatedAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople.universalIdentifier
```
Reach for the helpers above when the object is one **your app** defines with [`defineObject()`](/developers/extend/apps/data/objects), where no such constant exists.
<Note>
`name` is a **default** field, not a system field. It keeps its own hardcoded
universal identifier and is not resolved through
`getFieldUniversalIdentifier`. On objects you define, reference the
`name` field by the identifier you gave it in `defineObject()`.
</Note>

View File

@@ -0,0 +1,187 @@
---
title: Timeline Activity Types
description: Define automatic audit events and explicit app events that render in record timelines.
icon: 'clock-rotate-left'
---
A timeline activity type defines the stable vocabulary for an event shown in a record's timeline. Standard objects and app objects use the same contract: a type has a label and icon, can optionally declare when it is emitted, and can optionally render through one of your app's [front components](/developers/extend/apps/layout/front-components).
<Note>
Timeline activity types are in beta and are coming soon in Twenty 2.34. The
API can evolve while we learn from app developers' use cases.
</Note>
Create one with the scaffolder:
```bash filename="Terminal"
yarn twenty dev:add timelineActivityType
```
Or define it directly:
```ts filename="src/timeline-activity-types/post-card-created.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7500',
name: 'postCardCreated',
label: 'created a post card',
icon: 'IconMail',
emit: {
on: 'created',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
},
});
```
## Automatic and explicit events
Add `emit` when Twenty should create this type automatically. `emit.on` supports `created`, `updated`, `deleted`, `restored`, `linked`, and `unlinked`, while `emit.objectUniversalIdentifier` identifies the source object. Only one effective type can handle the same emit key—`on`, object, and optional through relation—in a workspace.
Without `emit.through`, the event is written on the source record's own timeline. To fan it out to related records, set `emit.through.relationFieldUniversalIdentifier` to either a direct many-to-one relation or a [one-to-many junction relation](/developers/extend/apps/data/relations#junction-relations) on the source object. Morph relations fan out to every member of their morph group, so a single declaration can target several object types.
For a direct relation, creating or restoring the source record produces `linked`, deleting it produces `unlinked`, and repointing the relation produces `unlinked` on the previous target plus `linked` on the new target. Other source updates do not produce link events. This is the contract used by attachments, whose target is a direct morph relation.
For a junction relation, `universalSettings.junctionTargetFieldUniversalIdentifier` identifies the relation from the junction object to the target. Creating or deleting a junction row produces `linked` or `unlinked`. Repointing either side of a junction row also produces a link event; updates to unrelated junction fields do not.
`linked` and `unlinked` events require `emit.through`, because their trigger is a change to the configured direct or junction relation.
For example, this is the same generic contract used by notes, tasks, messages, and calendar events:
```ts filename="src/timeline-activity-types/post-card-linked.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/post-card-recipients-on-post-card.field';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7502',
name: 'postCardLinked',
label: 'received a post card',
icon: 'IconMail',
emit: {
on: 'linked',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
through: {
relationFieldUniversalIdentifier:
POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER,
},
},
});
```
An `updated` through event is emitted for every source update by default. Set `emit.through.triggerFieldUniversalIdentifiers` when only changes to selected source fields should appear on the target timelines.
A `linked` event is timestamped at the moment it is written. When the source record carries its own moment in time, set `emit.through.happensAtFieldUniversalIdentifier` to a `DATE_TIME` or `DATE` field on the source object: the timeline anchors the event at that field's value instead, and follows it when the value changes. Twenty's standard email and calendar events use this to anchor at `receivedAt` and `startsAt`.
Omit `emit` for an explicit domain event that your logic function creates itself. This avoids producing both an automatic audit row and an explicit row for the same operation.
```ts filename="src/timeline-activity-types/post-card-sent.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
});
```
Create explicit events with [`createTimelineActivity()`](/developers/extend/apps/logic/logic-functions#create-a-timeline-activity). App code uses stable universal identifiers; Twenty resolves the installation-specific metadata IDs.
## Custom rendering
Without a front component, Twenty renders a native generic row from the type label, icon, and linked-object metadata. This works for standard and custom objects without an object-specific renderer.
For custom details, set `frontComponentUniversalIdentifier` to a front component owned by the same app:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
The native row remains the collapsed presentation. Twenty mounts the front component only after the user expands that row, avoiding a sandbox and worker for every visible event. Inside the component, call `useTimelineActivityId()` from `twenty-sdk/front-component` to read the row ID and fetch any data your presentation needs. It returns `null` when the component is rendered outside a timeline row.
## Overriding another application's presentation
An app owns automatic timeline contracts for its own objects. To customize an event on an object owned by another app, declare the existing type explicitly with `replacesTimelineActivityTypeUniversalIdentifier`:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7503',
name: 'companyCreatedWithDeploymentContext',
label: 'was created from a deployment',
emit: {
on: 'created',
objectUniversalIdentifier: COMPANY_UNIVERSAL_IDENTIFIER,
},
replacesTimelineActivityTypeUniversalIdentifier:
RECORD_CREATED_TIMELINE_ACTIVITY_TYPE_UNIVERSAL_IDENTIFIER,
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
An override must include `emit`, because it replaces an existing automatic emit slot rather than an explicit-only type. The referenced type must belong to the target object's application and describe the same action and route. Removing the overriding app restores the base type; removing the base type disables the override.
## Workspace overrides and muting
Workspace administrators can change a type's presentation or mute its automatic and explicit events without forking the application. Use the metadata API's `updateTimelineActivityType` mutation with the installation-specific type ID:
```graphql
mutation CustomizeTimelineActivityType(
$id: UUID!
$label: String
$icon: String
$isActive: Boolean
) {
updateTimelineActivityType(
input: { id: $id, label: $label, icon: $icon, isActive: $isActive }
) {
id
label
icon
isActive
}
}
```
`label` and `icon` are stored as workspace overrides, so later application updates do not overwrite the administrator's choices. Set `isActive` to `false` to stop automatic emission and reject new explicit events of that type. Existing rows remain visible.
Restore the application defaults, including the active state, with:
```graphql
mutation ResetTimelineActivityType($id: UUID!) {
resetTimelineActivityType(id: $id) {
id
label
icon
isActive
}
}
```
## Configuration fields
| Field | Required | Description |
| ------------------------------------------------- | --------- | ---------------------------------------------------------------- |
| `universalIdentifier` | Yes | Stable UUID for the type across installations and upgrades |
| `name` | Yes | App-local programmatic name; must be unique within the app |
| `label` | Yes | User-facing action text used by the native renderer |
| `icon` | No | Twenty icon name displayed beside the event |
| `emit` | No | Automatic emission declaration; omit for explicit-only types |
| `emit.on` | With emit | Audit action that causes this type to be written |
| `emit.objectUniversalIdentifier` | With emit | Object whose records emit this type |
| `emit.through.relationFieldUniversalIdentifier` | No | Direct or junction relation used to fan out to related timelines |
| `emit.through.triggerFieldUniversalIdentifiers` | No | Source fields that can trigger an `updated` through event |
| `emit.through.happensAtFieldUniversalIdentifier` | No | Source date field used as the event's `happensAt` timestamp for `linked` events, instead of the write time |
| `frontComponentUniversalIdentifier` | No | App-owned front component mounted when the row is expanded |
| `replacesTimelineActivityTypeUniversalIdentifier` | No | Existing emit type replaced; requires `emit` |
Twenty snapshots semantic identity and durable presentation fallbacks when an event is created. Historical rows keep their original action and object meaning. While the type is installed, its current translated label, icon, and front component render live; after uninstall, the snapshot fallback keeps the row readable. Resolution uses the universal identifier, so presentation also survives reinstalling the app.

View File

@@ -0,0 +1,102 @@
---
title: Concepts
description: How Twenty apps work — entity model, sandboxing, and the install lifecycle.
icon: "sitemap"
---
Twenty apps are TypeScript packages that extend your workspace with custom objects, logic, UI components, and AI capabilities. They run on the Twenty platform with full sandboxing and permission controls.
## How apps work
An app is a collection of **entities** declared using `defineEntity()` functions from the `twenty-sdk` package. The SDK detects these declarations via AST analysis at build time and produces a **manifest** — a complete description of what your app adds to a workspace. These functions validate your configuration at build time and provide IDE autocompletion and type safety.
```
your-app/
├── src/
│ ├── application-config.ts ← defineApplication (required, one per app)
│ ├── roles/ ← defineRole
│ ├── objects/ ← defineObject
│ ├── fields/ ← defineField
│ ├── logic-functions/ ← defineLogicFunction
│ ├── front-components/ ← defineFrontComponent
│ ├── skills/ ← defineSkill
│ ├── agents/ ← defineAgent
│ ├── views/ ← defineView
│ ├── navigation-menu-items/ ← defineNavigationMenuItem
│ └── page-layouts/ ← definePageLayout
├── public/ ← Static assets (images, icons)
└── package.json
```
<Note>
**File organization is up to you.** Entity detection is AST-based — the SDK finds `export default defineEntity(...)` calls regardless of where the file lives. The folder structure above is a convention, not a requirement.
</Note>
## Entity types
| Entity | Purpose | Docs |
|--------|---------|------|
| **Application** | App identity, default role, variables | [Application Config](/developers/extend/apps/config/application) |
| **Role** | Permission sets on objects and fields | [Roles & Permissions](/developers/extend/apps/config/roles) |
| **Object** | Custom record types with fields | [Objects](/developers/extend/apps/data/objects) |
| **Field** | Add fields to objects from other apps | [Extending Objects](/developers/extend/apps/data/extending-objects) |
| **Relation** | Bidirectional links between objects | [Relations](/developers/extend/apps/data/relations) |
| **Logic Function** | Server-side TypeScript with triggers | [Logic Functions](/developers/extend/apps/logic/logic-functions) |
| **Skill** | Reusable AI agent instructions | [Skills & Agents](/developers/extend/apps/logic/skills-and-agents) |
| **Agent** | AI assistants with custom prompts | [Skills & Agents](/developers/extend/apps/logic/skills-and-agents) |
| **Connection Provider** | OAuth credentials for third-party APIs | [Connections](/developers/extend/apps/logic/connections) |
| **View** | Pre-configured record list views | [Views](/developers/extend/apps/layout/views) |
| **Navigation Menu Item** | Custom sidebar entries | [Navigation Menu Items](/developers/extend/apps/layout/navigation-menu-items) |
| **Page Layout** | Tabs and widgets on a record's detail page | [Page Layouts](/developers/extend/apps/layout/page-layouts) |
| **Front Component** | Sandboxed React UI inside Twenty | [Front Components](/developers/extend/apps/layout/front-components) |
| **Shared Dependencies** | Dependencies shared by the app's front components, declared in package.json | [Shared Dependencies](/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components) |
| **Command Menu Item** | Quick actions and Cmd+K entries | [Command Menu Items](/developers/extend/apps/layout/command-menu-items) |
## Sandboxing
- **Logic functions** run in isolated Node.js processes on the server. They only access data through the typed API client, which acts either as the person who triggered the run or as the app itself. See [Whose access a call uses](/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses).
- **Front components** run in Web Workers using Remote DOM — sandboxed from the main page but rendering native DOM elements (not iframes). They communicate with Twenty via a message-passing host API.
- **Permissions** are enforced at the API level. Two runtime tokens are injected, both derived from the role declared with `defineApplicationRole()`; the default one is narrowed further to what the triggering person can do whenever someone is behind the run.
## App lifecycle
```
┌─────────────────────────────────────────────────────────┐
│ Development │
│ npx create-twenty-app → yarn twenty dev (live sync) │
├─────────────────────────────────────────────────────────┤
│ Build & Deploy │
│ yarn twenty dev:build → yarn twenty app:publish │
├─────────────────────────────────────────────────────────┤
│ Install flow │
│ upload → [pre-install] → metadata migration → │
│ generate SDK → [post-install] → … → [uninstall] │
├─────────────────────────────────────────────────────────┤
│ Publish │
│ npm publish → appears in Twenty marketplace │
└─────────────────────────────────────────────────────────┘
```
- **`yarn twenty dev`** — watches your source files and live-syncs changes to a connected Twenty server. The typed API client is regenerated automatically when the schema changes.
- **`yarn twenty dev:build`** — compiles TypeScript, bundles logic functions and front components with esbuild, and produces a manifest.
- **Pre/post-install and uninstall hooks** — optional functions that run during installation or right before removal. See [Install Hooks](/developers/extend/apps/config/install-hooks) for details.
## Next steps
<CardGroup cols={2}>
<Card title="Config" icon="screwdriver-wrench" href="/developers/extend/apps/config/overview">
Application identity, default role, and install and uninstall hooks.
</Card>
<Card title="Data" icon="database" href="/developers/extend/apps/data/overview">
Objects, fields, and bidirectional relations.
</Card>
<Card title="Logic" icon="bolt" href="/developers/extend/apps/logic/overview">
Logic functions, skills, agents, and OAuth connections.
</Card>
<Card title="Layout" icon="table-columns" href="/developers/extend/apps/layout/overview">
Views, navigation, page layouts, front components.
</Card>
<Card title="Operations" icon="rocket" href="/developers/extend/apps/operations/overview">
CLI, testing, remotes, CI, and publishing your app.
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: Local Server
description: Manage the local Twenty Docker server — start, stop, upgrade, parallel test instance, and manual SDK setup.
icon: "server"
---
## Managing the local server
Use `yarn twenty docker:*` to control the local Twenty container:
| Command | What it does |
|---------|--------------|
| `yarn twenty docker:start` | Start the server (pulls the image if needed) |
| `yarn twenty docker:start 2.2.0` | Start a specific server version |
| `yarn twenty docker:start --port 3030` | Start on a custom port |
| `yarn twenty docker:stop` | Stop the server (preserves data) |
| `yarn twenty docker:status` | Show URL, version, and login credentials |
| `yarn twenty docker:logs` | Stream server logs |
| `yarn twenty docker:reset` | Wipe data and start fresh |
| `yarn twenty docker:upgrade` | Pull the latest `twenty-app-dev` image |
| `yarn twenty docker:upgrade 2.2.0` | Upgrade to a specific version |
Data persists across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything.
## Pinning the server version
When no version is passed, `docker:start` resolves the version from your app's `engines.twenty` range in `package.json` — the same range the server validates against when your app is installed. It starts the newest published `twenty-app-dev` image that satisfies the range, falling back to `latest` when the field is absent or no published version matches:
```json filename="package.json"
{
"engines": {
"twenty": ">=2.2.0"
}
}
```
Pass a version explicitly to override the range for a single run: `yarn twenty docker:start 2.3.0`. If a container already exists on a different version, `docker:start` upgrades it in place (recreating the container while preserving your data volumes).
## Upgrading the server image
`yarn twenty docker:upgrade` pulls the latest image, compares digests, and only recreates the container if anything actually changed. Volumes are preserved — only the container is replaced. If a new image was pulled and the container was running, the upgrade automatically starts a new container; run `yarn twenty docker:start` afterward to wait for it to become healthy.
```bash filename="Terminal"
yarn twenty docker:upgrade # Latest
yarn twenty docker:upgrade 2.2.0 # Specific version
```
Verify the running version with `yarn twenty docker:status` (it shows the `APP_VERSION` baked into the container).
## Running a parallel test instance
Pass `--test` to any `docker:*` command to manage a second, fully isolated instance — useful for integration tests or experiments without touching your main dev data:
| Command | What it does |
|---------|--------------|
| `yarn twenty docker:start --test` | Start the test instance (defaults to port 2021) |
| `yarn twenty docker:stop --test` | Stop it |
| `yarn twenty docker:status --test` | Show its status |
| `yarn twenty docker:logs --test` | Stream its logs |
| `yarn twenty docker:reset --test` | Wipe its data |
| `yarn twenty docker:upgrade --test` | Upgrade its image |
The test instance has its own container (`twenty-app-dev-test`), volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`), and config — it runs alongside your main instance without conflicts. Combine `--test` with `--port` to override 2021.
## Manual setup (without the scaffolder)
Skip the scaffolder if you're adding the SDK to an existing project:
```bash filename="Terminal"
yarn add twenty-sdk twenty-client-sdk
```
Add the script to `package.json`:
```json filename="package.json"
{
"scripts": {
"twenty": "twenty"
}
}
```
You can now run `yarn twenty dev`, `yarn twenty docker:start`, and the rest.
<Note>
Don't install `twenty-sdk` globally — pin it per project so each app uses its own version.
</Note>

View File

@@ -0,0 +1,82 @@
---
title: Project Structure
description: What's inside a scaffolded Twenty app — files, folders, and what each one does.
icon: "folder-tree"
---
A new app generated by `npx create-twenty-app` looks like this:
```text filename="my-twenty-app/"
my-twenty-app/
package.json
src/
application-config.ts # Required — your app's entry point
default-role.ts # Permissions for logic functions
constants/
universal-identifiers.ts # Auto-generated UUIDs and metadata
front-components/
main-page.tsx # Welcome page component
navigation-menu-items/
main-page.navigation-menu-item.ts # Sidebar entry for the welcome page
page-layouts/
main-page.page-layout.ts # Standalone page hosting the component
__tests__/
application-config.test.ts # Unit test
global-setup.ts # Integration test setup (sync + uninstall)
schema.integration-test.ts # Integration test against a live server
.github/workflows/
ci.yml # Lint, typecheck, unit + integration tests
cd.yml # Deploy + install on push to main
publish.yml # Publish to npm on version tags (with provenance)
public/
logo.svg # Static assets
locales/
en.json # Source catalog; add <locale>.json files next to it
vitest.config.ts # Integration test runner config
vitest.unit.config.ts # Unit test runner config
tsconfig.json, tsconfig.spec.json
.nvmrc, .yarnrc.yml, .oxlintrc.json
README.md, AGENTS.md, CLAUDE.md, CHANGELOG.md, SETUP.md
```
## Key files
| File / Folder | Purpose |
|---|---|
| `src/application-config.ts` | **Required.** The main configuration file for your app. |
| `src/default-role.ts` | Default role controlling what your logic functions can access. |
| `src/constants/universal-identifiers.ts` | Auto-generated UUIDs and metadata (display name, description). |
| `src/front-components/`, `src/navigation-menu-items/`, `src/page-layouts/` | A starter welcome page: a front component rendered by a standalone page layout, reachable from the sidebar. |
| `src/__tests__/` | A unit test plus an integration test (with its global setup) that syncs the app against a real server. |
| `public/` | Static assets (images, fonts) served with your app. |
| `locales/` | Translation catalogs. `en.json` is the source catalog `dev:translations-extract` fills in; the set of files here is what a sync publishes, so an empty folder declares no translations. `locales/compiled/` is written by `pull` for entries whose source strings are not in the tree yet, is published by a sync too, and is not meant to be edited by hand. |
| `AGENTS.md` / `CLAUDE.md` | Guidance for AI coding agents working on the app. |
| `CHANGELOG.md` / `SETUP.md` | Changelog of notable changes and setup instructions for local development. |
<Note>
**File organization is up to you.** The folders above are conventions — the SDK detects entities via AST analysis on `export default defineEntity(...)` calls regardless of where the file lives.
</Note>
## Dependencies
The Twenty SDK and UI packages belong under `devDependencies`:
```json filename="package.json"
{
"dependencies": {},
"devDependencies": {
"twenty-client-sdk": "2.40.0",
"twenty-sdk": "2.40.0",
"twenty-ui": "2.40.0"
}
}
```
The scaffolder pins `twenty-sdk`, `twenty-client-sdk`, and `twenty-ui` to its own version. Keep all three in sync when upgrading.
- **`twenty-sdk`** ships the `twenty` CLI and the build/scaffolding tooling. It only runs at development and build time and is never imported by your published app's runtime.
- **`twenty-client-sdk`** _is_ imported by your app code (`CoreApiClient`, `MetadataApiClient`, `RestApiClient`), but Twenty provides it at runtime — logic functions get it from a generated SDK layer, and front components resolve it from server-served modules. Your installed copy is only used for typechecking and the deploy-time build, so it never needs to ship in the deployed bundle.
Keeping either package under `dependencies` pulls it into the installed app's runtime bundle, where it is dead weight. `twenty dev:build` emits a warning when either is still listed under `dependencies`.
Add your app's own runtime dependencies (libraries your logic functions actually import at runtime) under `dependencies` as usual. Keep the tree lean: `dependencies` are installed into a runtime layer capped at 250MB unpacked, and a sync or install fails with a dependencies size error beyond that — UI libraries and dev tooling belong in `devDependencies`.

View File

@@ -0,0 +1,186 @@
---
title: Quick Start
icon: "rocket"
description: Create your first Twenty app in minutes.
---
## Prerequisites
- **Node.js 24.5+** — [Download](https://nodejs.org/)
- **Yarn 4** — bundled with Node via Corepack. Enable it: `corepack enable`
- **Docker** — [Download](https://www.docker.com/products/docker-desktop/). Needed to run a local Twenty server. Skip if you already have Twenty running elsewhere.
Building a Twenty app has three phases. The scaffolder collapses them into one happy-path command, but each phase is a separate concept — when something fails, knowing which phase you're in tells you what to fix.
| Phase | What you do | Tool | Result |
|---|---|---|---|
| **1. Scaffold** | Generate the app's source code | `npx create-twenty-app` | A TypeScript project on disk |
| **2. Run a server** | Start a Twenty server to sync into | Docker + `yarn twenty docker:start` | A running Twenty instance |
| **3. Sync** | Live-sync your code to the server | `yarn twenty dev` | Your changes appear in the UI |
---
## Phase 1 — Scaffold your project
Create a new app from the template:
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app
```
The scaffolder is non-interactive: the directory name becomes the app name. Pass `--display-name` and `--description` to customize the generated metadata (you can also edit it later in `src/constants/universal-identifiers.ts`). This generates a TypeScript project in `my-twenty-app/` with a starter `application-config.ts`, a default role, CI/CD workflows, and an integration test.
**After this phase:** you have an app's source code on your machine. It isn't running yet — that's Phase 2.
---
## Phase 2 — Run a local Twenty server
Your app needs a Twenty server to sync into. The server is a full Twenty instance — UI, GraphQL API, PostgreSQL — running locally in Docker. Your local code uploads its definitions to that server, which makes them appear in the UI.
The scaffolder starts one for you: with Docker running, it pulls the `twentycrm/twenty-app-dev` image, starts it on port `2020`, and authenticates the CLI against the pre-seeded demo workspace (`tim@apple.dev`) — no sign-in required.
To connect to an existing Twenty server instead, pass `--url <your-server-url>`. Remote servers authenticate with OAuth: a browser opens so you can sign in and click **Authorize**, which gives the CLI access to your workspace. (You can also opt into OAuth locally with `--authentication-method oauth` — sign in with `tim@apple.dev` / `tim@apple.dev`.)
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/login.png" alt="Twenty login screen" />
</div>
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/authorize.png" alt="Twenty CLI authorization screen" />
</div>
Your terminal will confirm everything is set up.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/scaffolded.png" alt="App scaffolded successfully" />
</div>
**After this phase:** you have a running Twenty server at [http://localhost:2020](http://localhost:2020) with your CLI authorized to sync to it.
<Note>
If Docker isn't installed or running, the scaffolder will tell you the right start command for your OS. Once Docker is up, you can resume with `yarn twenty docker:start` — no need to re-scaffold.
</Note>
---
## Phase 3 — Sync your changes
This is the inner loop you'll spend most of your time in.
```bash filename="Terminal"
cd my-twenty-app
yarn twenty dev
```
This watches `src/`, rebuilds on every change, and syncs the result to the server. Edit a file, save, and within a few seconds the server reflects the change. You'll see a live status panel in your terminal.
For more detailed output (build logs, sync requests, error traces), add `--verbose`.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/dev.png" alt="Dev mode terminal output" />
</div>
Open [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer). You should see your app under **Your Apps**.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="Your Apps list showing My twenty app" />
</div>
Click **My twenty app** to see its **application registration** — a server-level record describing your app (name, identifier, OAuth credentials, source). One registration can be installed across multiple workspaces on the same server.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="Application registration details" />
</div>
Click **View installed app** to see the workspace install. The **About** tab shows version and management options.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="Installed app" />
</div>
**After this phase:** you have a live development loop. Edit any file in `src/` and it appears in the UI.
### One-shot sync for CI and scripts
Use `plan` and `apply` to run the same pipeline once, without a watcher:
```bash filename="Terminal"
yarn twenty plan # preview the metadata changes without applying them
yarn twenty apply # show the plan, then apply it
```
| Command | Behavior | When to use |
|---------|----------|-------------|
| `yarn twenty dev` | Watches and re-syncs on every change. Runs until you stop it. | Interactive local development. |
| `yarn twenty apply` | Single build + sync, exits `0` on success, `1` on failure. Asks for confirmation on destructive changes (pass `--force` to skip). | CI, pre-commit hooks, AI agents, scripted workflows. |
| `yarn twenty plan` | Builds and prints the metadata changes **without applying them**. | Inspecting what a sync would change before committing to it. |
All modes need an authenticated remote. See [Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan) for more on `plan`.
<Note>
All three modes require your workspace to own the app's registration. The sync is refused when the `universalIdentifier` from your app config is registered to another workspace, or to no workspace yet — apps imported from the marketplace catalog start unowned and have to be claimed first. See [Registration ownership](/developers/extend/apps/operations/publishing#registration-ownership).
</Note>
<Note>
`yarn twenty dev --once` and `yarn twenty dev --once --dry-run` are deprecated aliases for `yarn twenty apply` and `yarn twenty plan`.
</Note>
### Dev mode options
| Flag | Description |
|------|-------------|
| `--force` | Apply destructive changes (deletes) without confirmation. |
| `--no-delete` | Keep entities that are missing from your source instead of deleting them. Also accepted by `plan` and `apply`. |
| `--debounceMs <ms>` | Set the file-change debounce delay in milliseconds (default: `1000`). |
| `--verbose` / `--debug` | Show detailed build logs, sync requests, and error traces. |
## What you can build
Apps are composed of **entities** — each defined as a TypeScript file with a single `export default`:
| Entity | What it does |
|--------|-------------|
| **Objects & Fields** | Custom data models (Post Card, Invoice, etc.) with typed fields |
| **Logic functions** | Server-side TypeScript triggered by HTTP routes, cron schedules, or database events |
| **Front components** | React components that render inside Twenty's UI (side panel, widgets, command menu) |
| **Skills & Agents** | AI capabilities — reusable instructions and autonomous assistants |
| **Views & Navigation** | Pre-configured list views and sidebar menu items |
| **Page layouts** | Custom record detail pages with tabs and widgets |
Full reference: [Concepts](/developers/extend/apps/getting-started/concepts).
## Build with AI agent skills
Official Twenty [Agent Skills](https://skills.sh/) teach coding agents the full app workflow: scaffolding, entity development, sync and deploy operations, and marketplace publishing. They install into Claude Code, Codex, Cursor, Pi, and any other harness supported by the `skills` CLI, and work against any Twenty instance, including self-hosted custom domains and localhost.
```bash filename="Terminal"
# List the available skills
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --list
# Install the scaffolding and development skills
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --skill create-app
npx skills add https://github.com/twentyhq/twenty/tree/agent-skills --skill develop-app
```
Each skill installs with its own reference docs. The `agent-skills` branch becomes available after the first successful publish from `main`; before that, follow the [local build instructions](https://github.com/twentyhq/twenty/tree/main/packages/twenty-agent-skills#local-development).
## Next steps
<CardGroup cols={2}>
<Card title="Config" icon="screwdriver-wrench" href="/developers/extend/apps/config/overview">
Application identity, default role, install and uninstall hooks, public assets.
</Card>
<Card title="Data" icon="database" href="/developers/extend/apps/data/overview">
Objects, fields, and bidirectional relations.
</Card>
<Card title="Logic" icon="bolt" href="/developers/extend/apps/logic/overview">
Logic functions, skills, agents, and OAuth connections.
</Card>
<Card title="Layout" icon="table-columns" href="/developers/extend/apps/layout/overview">
Views, navigation, page layouts, front components.
</Card>
<Card title="Operations" icon="rocket" href="/developers/extend/apps/operations/overview">
CLI, testing, remotes, CI, and publishing your app.
</Card>
</CardGroup>

View File

@@ -0,0 +1,64 @@
---
title: Scaffolding
description: Generate entity files interactively with yarn twenty dev:add — objects, fields, views, logic functions, and more.
icon: "wand-magic-sparkles"
---
Instead of creating entity files by hand, use the interactive scaffolder:
```bash filename="Terminal"
yarn twenty dev:add
```
It prompts you to pick an entity type and walks you through the required fields, then writes 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 dev:add object
yarn twenty dev:add logicFunction
yarn twenty dev:add frontComponent
```
## Available entity types
| Entity type | Command | Generated file |
|-------------|---------|----------------|
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Page layout widget | `yarn twenty dev:add pageLayoutWidget` | `src/page-layout-widgets/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
| Timeline activity type | `yarn twenty dev:add timelineActivityType` | `src/timeline-activity-types/<name>.ts` |
## What the scaffolder generates
Each entity type has its own template. For example, `yarn twenty dev: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 dev:add logicFunction --path src/custom-folder
```

View File

@@ -0,0 +1,15 @@
---
title: Troubleshooting
description: Common first-run issues — Docker, Node version, Yarn, dependencies.
icon: "wrench"
---
- **Docker errors** — Make sure Docker Desktop (or the daemon) is running before `yarn twenty docker:start`. The error message will show the right start command for your OS.
- **Wrong Node version** — Need 24.5+ (`engines.node: ^24.5.0`). Check with `node -v`.
- **Yarn 4 missing** — Run `corepack enable`.
- **Dependencies broken** — `rm -rf node_modules && yarn install`.
- **Empty `yarn.lock`** — The build fails with "yarn.lock is empty" when the lockfile exists but has no content; run `yarn install` to regenerate it.
- **`twenty-sdk` errors after upgrading to v2.8.0** — It moved from `dependencies` to `devDependencies` in v2.8.0. See [Project Structure → Dependencies](/developers/extend/apps/getting-started/project-structure#dependencies).
- **`twenty dev:build` warns about `twenty-client-sdk` under `dependencies`** — It is provided at runtime by Twenty, so it should be moved to `devDependencies` alongside `twenty-sdk`. See [Project Structure → Dependencies](/developers/extend/apps/getting-started/project-structure#dependencies).
Stuck? Ask on the [Twenty Discord](https://discord.com/channels/1130383047699738754/1130386664812982322).

View File

@@ -0,0 +1,119 @@
---
title: Command Menu Items
description: Surface front components as quick actions and command menu (Cmd+K) entries with defineCommandMenuItem.
icon: "terminal"
---
A **command menu item** is the bridge between the user and a [front component](/developers/extend/apps/layout/front-components). It registers the component in Twenty's command menu (Cmd+K) and, optionally, as a pinned quick-action button in the top-right corner of the page.
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
label: 'Open Dashboard',
shortLabel: 'Dashboard',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
## Configuration fields
| Field | Required | Description |
|-------|----------|-------------|
| `universalIdentifier` | Yes | Stable unique ID for the command |
| `label` | Yes | Full label shown in the command menu (Cmd+K) |
| `frontComponentUniversalIdentifier` | Yes | The `universalIdentifier` of the front component this command opens |
| `shortLabel` | No | Shorter label displayed on the pinned quick-action button |
| `icon` | No | **Deprecated** — ignored in favor of the application icon; the build emits a warning if set |
| `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), `'GLOBAL_OBJECT_CONTEXT'` (only on pages with an object context — index and record pages), `'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 that dynamically controls visibility (see below) |
| `conditionalPinnedExpression` | No | A boolean expression that narrows `isPinned`: the command shows as a quick-action button only while it holds, and stays in the command menu otherwise. Same syntax and variables as `conditionalAvailabilityExpression` |
## Headless commands
A command menu item paired with a [headless front component](/developers/extend/apps/layout/front-components#headless-vs-non-headless) is the idiomatic way to ship a one-click action — run code, navigate, or confirm and execute. The Front Components page covers the [SDK Command components](/developers/extend/apps/layout/front-components#sdk-command-components) (`Command`, `CommandLink`, `CommandModal`, `CommandOpenSidePanelPage`) that handle the action-and-unmount pattern.
A typical flow: a headless component renders `<Command execute={...} />` (see the [full example](/developers/extend/apps/layout/front-components#sdk-command-components)), and the command menu item points at it:
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
## 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:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '...',
label: 'Bulk Update',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: '...',
conditionalAvailabilityExpression: everyEquals(
objectPermissions,
'canUpdateObjectRecords',
true,
),
});
```
<Note>
`RECORD_SELECTION` already implies a non-empty selection — use `numberOfSelectedRecords` only for specific counts (e.g. `>= 2`).
</Note>
### 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 |

View File

@@ -0,0 +1,875 @@
---
title: Front Components
description: Build React components that render inside Twenty's UI with sandboxed isolation.
icon: "window-maximize"
---
Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code executes inside a sandboxed, opaque-origin iframe, yet its UI still renders natively in the page rather than being confined to that iframe.
<Warning>
Front components are still under active development. Your code runs against a partial DOM, not a real browser page, so advanced usages can fail, often silently. See [Current limitations](#current-limitations).
</Warning>
## Where front components can be used
Front components can render in three locations within Twenty:
- **Side panel** — Non-headless front components open in the right-hand side panel. This is the default behavior when a front component is triggered from the command menu.
- **Widgets (dashboards and record pages)** — Front components can be embedded as widgets inside [page layouts](/developers/extend/apps/layout/page-layouts). When configuring a dashboard or a record page layout, users can add a front component widget.
- **App settings** — Defined with [`defineSettingsFrontComponent()`](#custom-settings-component), the front component renders as a section inside the app's **Settings** tab, in place of the default variable configuration UI.
A front component on its own isn't reachable from the UI — you need to *surface* it. The three ways to do that are:
- **Pair it with a [command menu item](/developers/extend/apps/layout/command-menu-items)** — registers it in the command menu (Cmd+K) and, optionally, as a pinned quick-action.
- **Embed it as a widget in a [page layout](/developers/extend/apps/layout/page-layouts)** — places it on a record's detail page or dashboard.
- **Define it with [`defineSettingsFrontComponent()`](#custom-settings-component)** — renders it as a section inside the app's **Settings** tab, in place of the default variable configuration UI.
## Basic example
The quickest way to see a front component in action is to pair it with a [`defineCommandMenuItem`](/developers/extend/apps/layout/command-menu-items), so it appears as a quick-action button in the top-right corner of the page:
```tsx src/front-components/hello-world.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
const HelloWorld = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello from my app!</h1>
<p>This component renders inside Twenty.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
name: 'hello-world',
description: 'A simple front component',
component: HelloWorld,
});
```
```ts src/command-menu-items/hello-world.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
shortLabel: 'Hello',
label: 'Hello World',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
After syncing with `yarn twenty dev` (or running a one-shot `yarn twenty apply`), 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.
## 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) |
## 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 [Page Layouts](/developers/extend/apps/layout/page-layouts) for details.
## Custom settings component
To replace the auto-generated variable configuration UI in your app's **Settings** tab with your own component, define it with `defineSettingsFrontComponent` instead of `defineFrontComponent`. It takes the same [configuration fields](#configuration-fields) (except `isHeadless`, which is not accepted since a settings component always renders visible UI) and additionally marks the component as the app's settings UI.
The component renders as a section **inside** the Settings tab, not as a replacement for the whole tab. Twenty's system-managed sections — auto-upgrade, App URL, and connections — always render above it and cannot be overridden by the app.
```tsx src/front-components/app-settings.tsx
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
const AppSettings = () => {
return (
<div style={{ padding: '20px' }}>
<h2>My app settings</h2>
{/* render your own configuration UI here */}
</div>
);
};
export default defineSettingsFrontComponent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'app-settings',
description: "Custom UI for the app's Settings tab",
component: AppSettings,
});
```
Only one settings front component is allowed per app; declaring more than one fails the build. When present, the app's **Settings** tab renders this component in place of the default variable configuration UI.
## Headless vs non-headless
Front components come in two rendering modes controlled by the `isHeadless` option:
**Non-headless (default)** — The component renders a visible UI. When triggered from the command menu it opens in the side panel. This is the default behavior when `isHeadless` is `false` or omitted.
**Headless (`isHeadless: true`)** — The component mounts invisibly in the background. It does not open the side panel. Headless components are designed for actions that execute logic and then unmount themselves — for example, running an async task, navigating to a page, or showing a confirmation modal. They pair naturally with the SDK Command components described below.
```tsx src/front-components/sync-tracker.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds, enqueueSnackbar } from 'twenty-sdk/front-component';
import { useEffect } from 'react';
const SyncTracker = () => {
const [recordId] = useSelectedRecordIds();
useEffect(() => {
enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' });
}, [recordId]);
return null;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-tracker',
description: 'Tracks record views silently',
isHeadless: true,
component: SyncTracker,
});
```
Because the component returns `null`, Twenty skips rendering a container for it — no empty space appears in the layout. The component still has access to all hooks and the host communication API.
## SDK Command components
The `twenty-sdk` package provides four Command helper components designed for headless front components. Each component executes an action on mount, handles errors by showing a snackbar notification, and automatically unmounts the front component when done.
Import them from `twenty-sdk/front-component`:
- **`Command`** — Runs an async callback via the `execute` prop.
- **`CommandLink`** — Navigates to an app path. Props: `to`, `params`, `queryParams`, `options`.
- **`CommandModal`** — Opens a confirmation modal. If the user confirms, executes the `execute` callback. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
- **`CommandOpenSidePanelPage`** — Opens content in the side panel. Routed pages use the same typed `to`, `params`, and `queryParams` props as `CommandLink`, plus optional `hash`, `pageTitle`, and `resetNavigationStack` props. The workspace route registry decides whether the requested `AppPath` supports the side panel. `pageTitle` overrides the panel header title, while `resetNavigationStack` clears earlier panel pages before opening the route. Purpose-built panel tools use their own page-specific props.
Here is a full example of a headless front component using `Command` to run an action from the command menu:
```tsx src/front-components/run-action.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const RunAction = () => {
const execute = async () => {
const client = new CoreApiClient();
await client.mutation({
createTask: {
__args: { data: { title: 'Created by my app' } },
id: true,
},
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
name: 'run-action',
description: 'Creates a task from the command menu',
component: RunAction,
isHeadless: true,
});
```
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
And an example using `CommandModal` to ask for confirmation before executing:
```tsx src/front-components/delete-draft.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { CommandModal } from 'twenty-sdk/front-component';
const DeleteDraft = () => {
const execute = async () => {
// perform the deletion
};
return (
<CommandModal
title="Delete draft?"
subtitle="This action cannot be undone."
execute={execute}
confirmButtonText="Delete"
confirmButtonAccent="danger"
/>
);
};
export default defineFrontComponent({
universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
name: 'delete-draft',
description: 'Deletes a draft with confirmation',
component: DeleteDraft,
isHeadless: true,
});
```
And an example using `CommandOpenSidePanelPage` to open the current record in the side panel on a specific tab. `hash` is a page layout tab id without the leading `#` (default layouts use ids like `company-tab-emails` or `company-tab-timeline`; custom layouts use the tab's own id). If the id doesn't exist in the record's layout, the default tab opens instead:
```tsx src/front-components/open-company-emails.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AppPath,
CommandOpenSidePanelPage,
useSelectedRecordIds,
} from 'twenty-sdk/front-component';
const OpenCompanyEmails = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
if (!recordId) {
return null;
}
return (
<CommandOpenSidePanelPage
to={AppPath.RecordShowPage}
params={{
objectNameSingular: 'company',
objectRecordId: recordId,
}}
hash="company-tab-emails"
resetNavigationStack={false}
/>
);
};
export default defineFrontComponent({
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
name: 'open-company-emails',
description: 'Opens the current company on its Emails tab',
component: OpenCompanyEmails,
isHeadless: true,
});
```
## Calling a logic function
Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. `RestApiClient` treats paths starting with `/s/` as app routes, resolves them to the URL your functions are served from, and authenticates them with `TWENTY_APP_ACCESS_TOKEN`.
> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.withtwenty.com<path>`. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
A headless front component can run the call on mount via the `Command` component, then unmount automatically:
```tsx src/front-components/sync-prs.tsx
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
const SyncPrs = () => {
const execute = async () => {
await new RestApiClient().post('/s/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
The path passed to `RestApiClient` is the logic function's `httpRouteTriggerSettings.path`, prefixed with `/s`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
// ...fetch from GitHub and persist records...
return { ok: true };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-prs',
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-prs',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
<Note>
`TWENTY_APP_ACCESS_TOKEN` is injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
</Note>
### Calling the Twenty REST API
To call app HTTP routes or read and write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It sends `/s/...` paths to your workspace's functions base URL and every other path, including `/rest/...`, to `TWENTY_API_URL`.
It always acts as the person looking at the page. `runAs: 'application'` is a logic-function option only: a component never receives your application's own token, so asking for it here throws. Put work that needs the app's own access behind a logic function and call that instead.
| Method | Description |
|--------|-------------|
| `get(path, options?)` | Sends a `GET` request |
| `post(path, body?, options?)` | Sends a `POST` request |
| `put(path, body?, options?)` | Sends a `PUT` request |
| `patch(path, body?, options?)` | Sends a `PATCH` request |
| `delete(path, options?)` | Sends a `DELETE` request |
| `request(method, path, options?)` | Generic request with any HTTP method |
| `resolveUrl(path, options?)` | Resolves a path to its full URL without sending a request (for links) |
`options` accepts `headers`, `query` (a record of query-string params; nullish values are skipped), and an `AbortSignal` via `signal`. A non-`FormData` object `body` is JSON-serialized automatically. On a `401`, the client refreshes the access token once through the host and retries the request.
The base URL and token are resolved from the environment by default. Pass overrides to the constructor when needed — for example in tests:
```ts
const client = new RestApiClient({
baseUrl: 'https://myworkspace.twenty.com',
token: 'my-token',
});
```
Failed requests throw a `RestApiClientError` exposing `status`, `statusText`, `url`, and the parsed `body`:
```tsx
import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
const client = new RestApiClient();
try {
const people = await client.get('/rest/people', {
query: { limit: 10 },
});
} catch (error) {
if (error instanceof RestApiClientError) {
console.error(error.status, error.body);
}
}
```
## 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 } from 'twenty-sdk/define';
import {
useUserId,
useSelectedRecordIds,
useFrontComponentId,
} from 'twenty-sdk/front-component';
const RecordInfo = () => {
const userId = useUserId();
const [recordId] = useSelectedRecordIds();
const componentId = useFrontComponentId();
return (
<div>
<p>User: {userId}</p>
<p>Record: {recordId ?? 'No record context'}</p>
<p>Component: {componentId}</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012',
name: 'record-info',
component: RecordInfo,
});
```
Available hooks:
| Hook | Returns | Description |
|------|---------|-------------|
| `useUserId()` | `string` or `null` | The current user's ID |
| `useSelectedRecordIds()` | `string[]` | All selected record IDs (empty array if none selected) |
| `useRecordId()` | `string` or `null` | **Deprecated.** Use `useSelectedRecordIds()` instead |
| `useFrontComponentId()` | `string` | This component instance's ID |
| `useTimelineActivityId()` | `string` or `null` | The current timeline activity ID when rendering a custom timeline row |
| `useColorScheme()` | `'light'` or `'dark'` | The host UI's active color scheme (`System` is already resolved) |
| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function |
## Application variables
Application variables defined in [`defineApplication()`](/developers/extend/apps/config/application) with `isSecret: false` are available inside front components via the `getApplicationVariable` utility:
```tsx src/front-components/greeting.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';
const Greeting = () => {
const recipientName = getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World';
return <p>Hello, {recipientName}!</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'greeting',
component: Greeting,
});
```
<Warning>
Secret variables (`isSecret: true`) are **not** exposed to front components. They are only available in [logic functions](/developers/extend/apps/logic/logic-functions), which run server-side. This prevents sensitive values like API keys from being sent to the browser.
</Warning>
`getApplicationVariable` always returns a **string** (or `undefined`), regardless of the variable's declared `type`. The string is serialized consistently by type (booleans as `"true"` / `"false"`, numbers as decimal strings, arrays / objects as JSON), the same format used for logic-function `process.env` — parse it yourself (`Number(...)`, `JSON.parse(...)`, `=== 'true'`). See [Variable types](/developers/extend/apps/config/application#variable-types).
The following system variables are always available via `process.env`:
| Variable | Description |
|----------|-------------|
| `TWENTY_API_URL` | Base URL of the Twenty core API |
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to the signed-in person's role intersected with your app's, so a component can never do more than the person looking at it |
### `TWENTY_FUNCTIONS_URL`
Twenty also injects `TWENTY_FUNCTIONS_URL` into front components and logic functions: the base URL your app's HTTP-triggered logic functions are served from.
It exists because that URL is not always the Twenty server itself. On Twenty Cloud, app routes are served on a dedicated per-workspace domain (`https://<your-workspace-subdomain>.withtwenty.com`, or the application's primary public domain when one is configured) so that app-authored responses run on an isolated origin rather than on the Twenty app origin. Self-hosted and local instances serve app routes under the `/s` prefix on the server itself and may not set the variable at all. Since the base URL varies per workspace and per instance, your code cannot hard-code it — the server injects the right value at runtime.
You rarely need to read it directly. Call your routes through `RestApiClient` with a `/s/`-prefixed path and the client resolves the URL for you: it strips the `/s` prefix and targets `TWENTY_FUNCTIONS_URL`, falling back to `<TWENTY_API_URL>/s` when the variable is not set. Use `resolveUrl('/s/<path>')` to get the absolute URL without sending a request, e.g. for a link. Read the variable directly only when building a URL by hand:
```ts
const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
```
## 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 |
| `uploadFile(file, { fieldMetadataId, fileName? })` | Upload a `Blob` into a FILES field; resolves to `{ status: 'uploaded', file: { fileId, path, url, size, mimeType } }` or `{ status: 'failed', reason: 'invalid-params' \| 'upload-failed' }` |
Here is an example that uses the host API to show a snackbar and close the side panel after an action completes:
```tsx src/front-components/archive-record.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, closeSidePanel, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const ArchiveRecord = () => {
const [recordId] = useSelectedRecordIds();
const handleArchive = async () => {
const client = new CoreApiClient();
await client.mutation({
updateTask: {
__args: { id: recordId, data: { status: 'ARCHIVED' } },
id: true,
},
});
await enqueueSnackbar({
message: 'Record archived',
variant: 'success',
});
await closeSidePanel();
};
return (
<div style={{ padding: '20px' }}>
<p>Archive this record?</p>
<button onClick={handleArchive}>Archive</button>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
name: 'archive-record',
description: 'Archives the current record',
component: ArchiveRecord,
});
```
### Storage
`localStorage` and `sessionStorage` work as they do in a normal page, with the standard synchronous API. Your keys are scoped to your app install and the signed-in user: no other app can read them, and another user signing into the same browser starts from an empty store. Values written to `localStorage` stay on the device across reloads; `sessionStorage` lasts for the browser session.
```tsx src/front-components/note-draft.tsx
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const NoteDraft = () => {
const [draft, setDraft] = useState(() => localStorage.getItem('draft') ?? '');
const handleChange = (nextDraft: string) => {
setDraft(nextDraft);
localStorage.setItem('draft', nextDraft);
};
return (
<textarea value={draft} onChange={(event) => handleChange(event.target.value)} />
);
};
export default defineFrontComponent({
universalIdentifier: 'd0e1f2a3-b4c5-6789-def0-890123456789',
name: 'note-draft',
description: 'Keeps an unsaved note on this device',
component: NoteDraft,
});
```
Twenty stores the values on your app's behalf, so a write is applied locally right away and saved in the background. Reads never wait on the host. Nothing is synced: the values do not follow the user to another browser or machine, so use a logic function's [key-value store](/developers/extend/apps/logic/key-value-store) for anything that must survive a device change.
Writes are capped, and every limit counts characters rather than bytes: keys are at most 512 characters, a single value at most 262,144 characters, and each storage at most 1,048,576 characters per app and user. A write that breaks a limit throws a `QuotaExceededError`, like the browser API.
### Working with multiple records
Use `useSelectedRecordIds()` to handle multiple selected records. This is useful for bulk operations:
```tsx src/front-components/bulk-export.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const BulkExport = () => {
const selectedRecordIds = useSelectedRecordIds();
const handleExport = async () => {
const client = new CoreApiClient();
for (const recordId of selectedRecordIds) {
await client.mutation({
updateTask: {
__args: { id: recordId, data: { exported: true } },
id: true,
},
});
}
await enqueueSnackbar({
message: `Exported ${selectedRecordIds.length} records`,
variant: 'success',
});
await closeSidePanel();
};
return (
<div style={{ padding: '20px' }}>
<p>Export {selectedRecordIds.length} selected record(s)?</p>
<button onClick={handleExport}>Export</button>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
name: 'bulk-export',
description: 'Export selected records',
component: BulkExport,
});
```
Surface it with a [command menu item](/developers/extend/apps/layout/command-menu-items) restricted to record selections:
```ts src/command-menu-items/bulk-export.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
label: 'Bulk Export',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
});
```
## Public assets
Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
export default defineFrontComponent({
universalIdentifier: '...',
name: 'logo',
component: Logo,
});
```
See the [public assets section](/developers/extend/apps/config/public-assets) for details.
## Sharing dependencies across front components
By default, every front component bundles its own copy of the libraries it imports, so an app with five components ships React five times. Declare shared dependencies in your app's `package.json` to build those libraries once and have every component of the app load them from a single cached file:
```json package.json
{
"frontComponentSharedDependencies": [
"react",
"react-dom/client",
"twenty-ui/primitives/input"
]
}
```
Each component then imports its dependencies exactly as before — nothing changes in your component code:
```tsx src/front-components/counter.tsx
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'counter',
component: Counter,
});
```
A few things to know:
- **One shared dependencies bundle per app.** The bundle is built from your app's own dependencies, so you keep full control of the versions you ship.
- **List the exact specifiers you import.** `twenty-ui/primitives/input` and `twenty-ui/primitives/data-display` are two entries; a package name alone does not cover its subpaths. Listing `react` automatically covers `react/jsx-runtime`.
- **Share `react-dom/client` alongside `react`.** Every component renders through `createRoot`, so leaving it out means each component still bundles React DOM.
- **The bundle is cached.** It is served under a content-hash URL with a long-lived immutable cache, so it is downloaded once and reused across all components of the app until one of its dependencies changes.
- **Components that import none of the shared packages never download it.**
## Styling
Front components support multiple styling approaches. You can use:
- **Inline styles** — `style={{ color: 'red' }}`
- **Twenty UI components** — Twenty's own component library; see [Using Twenty UI components](#using-twenty-ui-components) below
- **Emotion** — CSS-in-JS with `@emotion/react`
- **Styled-components** — `styled.div` patterns
- **Tailwind CSS** — utility classes
- **Any CSS-in-JS library** compatible with React
## Using Twenty UI components
Twenty ships its component library as the [`twenty-ui`](https://www.npmjs.com/package/twenty-ui) package. Front components can use it for buttons, tags, status pills, chips, avatars, icons, typography, and theme tokens that automatically match the workspace's light and dark theme.
<Warning>
`twenty-ui` is still in alpha. Its version number follows the Twenty SDK
release cycle. APIs and component behavior may change between releases.
</Warning>
### Installation
`twenty-ui` is published alongside the SDK packages with the same version. Add it to your app, pinned to the version of `twenty-sdk` and `twenty-client-sdk` you use:
```bash
yarn add -D twenty-ui@2.40.0
```
`twenty-ui` is bundled into your front component at build time, so it only needs to be a dependency of your app — there is nothing to configure at runtime.
### Importing components
Import from the matching subpath rather than the package root, so only the components you use end up in your bundle:
All existing UI components are grouped under `twenty-ui/primitives/<family>`.
The `components/` folder is reserved for higher-level app building blocks.
| Subpath | What it exports |
| --- | --- |
| `twenty-ui/primitives/accessibility` | Hidden elements and keyboard interaction helpers |
| `twenty-ui/primitives/data-display` | Avatars, chips, tags, status indicators, and display helpers |
| `twenty-ui/primitives/feedback` | Toasts, banners, progress bars, and loaders |
| `twenty-ui/primitives/input` | Buttons, form controls, pickers, and code editors |
| `twenty-ui/primitives/json-visualizer` | JSON tree viewer |
| `twenty-ui/primitives/layout` | Layout, animation, resizing, and section components |
| `twenty-ui/primitives/navigation` | Links, list items, menus, and tabs |
| `twenty-ui/primitives/surfaces` | Cards, dialogs, menus, popovers, and tooltips |
| `twenty-ui/primitives/typography` | Text, headings, labels, and typography helpers |
| `twenty-ui/icon` | `Icon*` components (e.g. `IconCheck`) |
| `twenty-ui/theme-constants` | `ThemeProvider`, `themeCssVariables` |
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Button } from 'twenty-ui/primitives/input';
import { Status, Tag } from 'twenty-ui/primitives/data-display';
const StyledWidget = () => {
return (
<div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
<Button onClick={() => alert('Clicked!')}>Click me</Button>
<Tag color="green">Active</Tag>
<Status color="green">Online</Status>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
name: 'styled-widget',
component: StyledWidget,
});
```
### Icons
Import individual icons from `twenty-ui/icon`:
```tsx
import { IconBox, IconCheck } from 'twenty-ui/icon';
```
Each named icon is tree-shaken, so importing a handful adds little to your bundle. Avoid `IconsProvider`, `useIcons`, and `iconsState` — they pull in the full Tabler icon set (several MB).
### Theming and theme tokens
Twenty UI components automatically match the workspace's light and dark theme — the renderer applies the active color scheme on the host, and the components resolve their colors against it.
To use the same design tokens in your own inline styles, call the `useTheme()` hook. It returns Twenty's theme tokens (spacing, colors, radii, fonts) wired to the active theme, with no `ThemeProvider` setup needed in your component:
```tsx
import { useTheme } from 'twenty-ui/theme-constants';
const Card = () => {
const theme = useTheme();
return (
<div
style={{
padding: theme.spacing[4],
background: theme.background.secondary,
color: theme.font.color.primary,
}}
>
Themed card
</div>
);
};
```
Because `useTheme()` is a hook, you read tokens inside the component body, so the values always reflect the live theme. The same token map is also exported as the `themeCssVariables` constant, but prefer `useTheme()` in front components — a module-level constant that dereferences `themeCssVariables` can be undefined while the app manifest is extracted.
To branch on the active scheme explicitly, read it with `useColorScheme()` from `twenty-sdk/front-component`, which returns `'light'` or `'dark'`.
## Current limitations
Front components are under active development. Rendering, styling, handling events, measuring elements and browser storage work well. Anything that reaches *past* those (calling a DOM method on a ref, observing element resizes, portaling outside your tree) is missing or incomplete today, and most of it fails silently: no exception, and no TypeScript error either, since the scaffold is typed against the full browser DOM.
If one of these blocks you, [open an issue](https://github.com/twentyhq/twenty/issues/new/choose) so it gets prioritized.
### Layout and measurement
Elements can measure themselves: the host mirrors geometry into the sandbox, so reads are answered locally but can be up to one frame stale, and the first read of a never-measured element returns zeros. After writing, re-read in a `requestAnimationFrame` callback or an effect.
| API | What happens |
|-----|--------------|
| `getBoundingClientRect()`, `offsetWidth`, `clientWidth`, `scrollWidth`, `offsetTop`, ... | Work, from the mirror; assigning `scrollTop` / `scrollLeft` is a no-op |
| `getClientRects()`, `window.matchMedia()` | Throws |
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`, `scrollX`, `scrollY` | Work, reporting the **browser viewport**; your widget's own size is `document.body.clientWidth` / `clientHeight` |
| `window.getComputedStyle()` | Returns the element's inline styles only, never the host-computed cascade |
| `ResizeObserver`, `IntersectionObserver` | `ReferenceError` (`typeof` guards do work) |
| `MutationObserver` | Works, including `subtree`, `attributeFilter`, old values and `takeRecords()` |
Positioning from `getBoundingClientRect` now works, but anything watching size changes through `ResizeObserver` (recharts `ResponsiveContainer`, Floating UI's `autoUpdate`) still does not. Prefer CSS for layout anyway: your stylesheet reaches the real page, so flexbox, grid, `aspect-ratio`, `clamp()` and `@container` behave normally, with no frame lag.
<Note>
`requestAnimationFrame` works, with or without the `window.` prefix. Only Chrome runs a real animation frame inside a worker; Firefox and Safari get a 16 ms timer, so use it to defer a re-read past a write, not to drive an animation.
</Note>
### Global scope
The worker's own natives are also aliased onto `window`, so they work with or without the `window.` prefix: `fetch`, `setTimeout`/`clearTimeout`, `setInterval`/`clearInterval`, `queueMicrotask`, `console`, `crypto`, `performance`, `structuredClone`, `btoa`/`atob`, `URL`/`URLSearchParams`, `AbortController`/`AbortSignal`, `Headers`/`Request`/`Response`, `Blob`, `File`, `FormData` and `TextEncoder`/`TextDecoder`.
### DOM access
A `ref` gives you a sandbox element, not an `HTMLElement`.
| What you write | What happens | Use instead |
|----------------|--------------|-------------|
| `ref.current.focus()`, `.click()`, `.select()`, `.setSelectionRange()`, `.scrollIntoView()`, `video.play()` | Throws | Controlled components; read values from `event.target` |
| `document.createTreeWalker()` | Throws | `querySelector()` / `querySelectorAll()` and `getElementById()` work; `getElementsByClassName()` works too, but returns a static collection, not a live `HTMLCollection` |
| `document.activeElement` | Always `undefined` | Track focus with `onFocus` / `onBlur` |
| `canvas` | Renders nothing, no error | SVG, or draw offscreen and show the result in an `img` element |
| `createPortal(node, document.body)` | Renders nothing, while `isConnected` reports success | Overlays inline with `position: absolute`, or pass the library your own container element |
`element.classList` works, backed by the `class` attribute: `add`, `remove`, `toggle`, `replace`, `contains`, `item`, `length`, `value` and iteration all behave as they do in a browser, and `className` stays in sync.
The portal gap is why Radix, Headless UI, MUI and react-select popovers render nothing by default. Most accept a container prop; point it at an element you rendered.
### Events
Mouse, pointer, touch, drag, keyboard, focus, `input`/`change`/`submit`, `scroll`/`wheel`/`contextmenu` and `animationend`/`transitionend` cross to the host, plus a few per element: `load`/`error` on `img`, clipboard and composition on `input`/`textarea`, media on `video`/`audio`, `toggle` on `details`/`dialog`. Anything else (`onAuxClick`, `onSelect`, `onInvalid`, `onReset`, `onAnimationStart`, pointer capture, `onLoad` off `img`) is dropped without warning.
`document.addEventListener()` and `window.addEventListener()` register without error and never fire, which is why a drag stops as soon as the pointer leaves the element it started on. `event.preventDefault()` does not cross either; form submission, `dragover`/`drop` and link clicks are already guarded for you.
### Attributes and styling
Each element forwards its own properties to the host DOM (`href` on `a`, `src`/`alt` on `img`, `value`/`placeholder`/`disabled` on `input`, and so on), plus a common set on every element: `id`, `className`, `style`, `title`, `tabIndex`, `role`, `draggable` and any `aria-*` / `data-*` attribute (hyphenated, so `ariaLabel` is dropped). Anything outside that is silently discarded, so express custom state as `data-*`.
Component CSS, whether from `import './styles.css'`, CSS-in-JS or a `style` element, is injected into the host page's `head` **unscoped**. So class names collide with Twenty's own (prefix them, and never write bare `div { ... }` selectors), and `@media` matches the browser window rather than your widget (use `@container` with your own `container-type`). Inline `style` props are unaffected.
### Storage and network
`localStorage` and `sessionStorage` are provided by Twenty rather than the browser: the component runs in a worker at an opaque origin, so the host stores the values on your app's behalf. See [storage](#storage) for their scoping and limits. IndexedDB, cookies, the Cache API and `BroadcastChannel` remain unavailable. To persist state across devices, call a [logic function](/developers/extend/apps/logic/logic-functions) and use its [key-value store](/developers/extend/apps/logic/key-value-store).
`fetch` works, with caveats:
- Calls to the Twenty API and your app's routes are proxied by the host, so prefer [`RestApiClient`](#calling-the-twenty-rest-api). On proxied calls, `AbortSignal` and the other `RequestInit` options are dropped, and only `string` and `URLSearchParams` bodies are supported.
- Other origins leave the sandbox with `Origin: null`, so a third-party API answers only if it sends `Access-Control-Allow-Origin: *`. Call it from a logic function instead.
- `fetch('/rest/people')` is never matched to the Twenty API, because the sandbox has no page URL to resolve a relative path against.
### Media capture
`navigator.mediaDevices.getUserMedia()` and `MediaRecorder` work inside front components through sandbox polyfills, so standard recording code runs unchanged and `MediaRecorder.isTypeSupported` answers for common container/codec combinations. Detailed `getUserMedia` constraint objects are accepted but not forwarded — the host captures with its defaults for the requested kinds — and only one capture can be live at a time across applications. Store a recorded `Blob` with the `uploadFile` host function.
### Other gaps
- **File contents.** An `input` of type `file` gives your handler file metadata only, not the bytes, so `FileReader` is not available. To upload a `Blob` your code already holds — e.g. one produced by `MediaRecorder` — use the `uploadFile` host function.
- **Drag-and-drop payloads.** Drag events fire, but `event.dataTransfer` is `undefined`.
- **Node built-ins.** `fs`, `path` and `node:crypto` fail the build, so move that work into a [logic function](/developers/extend/apps/logic/logic-functions). Web Crypto replaces `node:crypto`, and `fetch`, `TextEncoder` and `URL` are available too, see [global scope](#global-scope) — but nothing replaces `fs`: the sandbox has no filesystem.
- **`iframe`** is always re-sandboxed without `allow-same-origin`, so an embed relying on its own session renders logged out. It has no `onLoad` either.

View File

@@ -0,0 +1,44 @@
---
title: Navigation Menu Items
description: Add custom entries to the workspace sidebar — links to saved views or external URLs.
icon: "bars"
---
A **navigation menu item** is an entry in the left sidebar. Use `defineNavigationMenuItem()` to ship custom sidebar links — typically one per [view](/developers/extend/apps/layout/views) you ship — or to point at external URLs.
```ts src/navigation-menu-items/example-navigation-menu-item.ts
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
name: 'example-navigation-menu-item',
icon: 'IconCompass',
color: 'blue',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
```
## Key points
- `type` determines what the menu item links to. Each type pairs with a specific identifier field:
| Type | What it does | Required field |
|------|--------------|----------------|
| `NavigationMenuItemType.VIEW` | Opens a saved view | `viewUniversalIdentifier` |
| `NavigationMenuItemType.LINK` | Opens an external URL | `link` |
| `NavigationMenuItemType.FOLDER` | Groups nested items under a label | `name` (and child items reference the folder via `folderUniversalIdentifier`) |
| `NavigationMenuItemType.OBJECT` | Opens an object's default index page | `targetObjectUniversalIdentifier` |
| `NavigationMenuItemType.PAGE_LAYOUT` | Opens a standalone page layout | `pageLayoutUniversalIdentifier` |
- `position` controls ordering in the sidebar.
- The enum also contains `NavigationMenuItemType.RECORD`, used internally for user-created record favorites — it isn't usable from an app manifest (there is no field to reference a record).
- `icon` and `color` are optional and customize how the entry looks.
- `folderUniversalIdentifier` is also available on any item to nest it inside a `FOLDER`-type parent.
- `yarn twenty pull` writes your navigation menu items back as `defineNavigationMenuItem()` files. An item a workspace member added to their own sidebar, and one pinned to a record, belong to that workspace rather than to your app and are reported instead; see [what pull does not write](/developers/extend/apps/operations/sync-and-recovery#pulling-an-installed-app-back-to-source).
<Note>
**Common pitfall:** creating an object without an associated navigation menu item makes that object unreachable from the sidebar. Unless it's a technical/internal object, every custom object should have a sidebar entry pointing at it.
</Note>

View File

@@ -0,0 +1,59 @@
---
title: Overview
description: Place your app inside Twenty's UI — sidebar entries, saved views, record page tabs, and sandboxed React components.
icon: "table-columns"
---
A Twenty app's **layout layer** is everything the user sees: where the app surfaces in the sidebar, which list views it ships, how its record detail pages are arranged, and which custom React components render inside those pages.
```text
Sidebar Record list Record detail page
─────── ─────────── ──────────────────
[📋 My View] ────▶ ┌──────────┐ ┌─────────────────────┐
[📋 Drafts ] │ Companies│ │ Tabs: [Overview ] │
[📋 Inbox ] │ ──────── │ │ [Notes ] │
▲ │ Apple │ │ [Hello ]◀──── definePageLayoutTab
│ │ Acme │ │ │ adds a tab...
└ defineNavi- │ … │ │ ┌────────────────┐ │
gationMenu- └────▲─────┘ │ │ │ │
Item points │ │ │ React UI │◀── …with a
to a defineView │ │ │ (sandboxed in │ │ defineFrontComponent
└ defineView │ │ a Worker) │ │ widget inside
picks columns │ └────────────────┘ │
and filters └─────────────────────┘
```
## In this section
<CardGroup cols={2}>
<Card title="Views" icon="list" href="/developers/extend/apps/layout/views">
`defineView` — saved list configurations: visible columns, filters, groups.
</Card>
<Card title="Navigation Menu Items" icon="bars" href="/developers/extend/apps/layout/navigation-menu-items">
`defineNavigationMenuItem` — sidebar entries pointing at views or external URLs.
</Card>
<Card title="Page Layouts" icon="table-columns" href="/developers/extend/apps/layout/page-layouts">
`definePageLayout`, `definePageLayoutTab` and `definePageLayoutWidget` — tabs and widgets on a record's detail page.
</Card>
<Card title="Front Components" icon="window-maximize" href="/developers/extend/apps/layout/front-components">
`defineFrontComponent` — sandboxed React components that render inside Twenty.
</Card>
<Card title="Command Menu Items" icon="terminal" href="/developers/extend/apps/layout/command-menu-items">
`defineCommandMenuItem` — register front components as Cmd+K entries and quick actions.
</Card>
</CardGroup>
## Where the app surfaces
| Surface | What it controls | Entity |
|---------|------------------|--------|
| **Sidebar** | A custom entry linking to a saved view or external URL | `defineNavigationMenuItem` |
| **Record list** | A saved configuration for an object — visible columns, order, filters, groups | `defineView` |
| **Record detail page** | The tabs and widgets on a record page (your own object's, or a standard one) | `definePageLayout`, `definePageLayoutTab`, `definePageLayoutWidget` |
| **Inside any of the above** | A custom React widget — buttons, forms, dashboards, integrations | `defineFrontComponent` |
| **App settings** | A custom configuration section inside the app's Settings tab, in place of the default variables UI | `defineSettingsFrontComponent` |
| **Command menu (Cmd+K)** | A pinned quick action or hidden command | `defineCommandMenuItem` |
Front components run inside an isolated Web Worker using Remote DOM — they render *natively* in the page (not inside an iframe), but cannot reach the host page or DOM directly. Communication with Twenty happens through a message-passing host API.
They are still under active development: the sandbox implements a partial DOM, so advanced usages can fail. See [Current limitations](/developers/extend/apps/layout/front-components#current-limitations).

View File

@@ -0,0 +1,250 @@
---
title: Page Layouts
description: Customize record detail pages — tabs, widgets, and where front components render — using definePageLayout, definePageLayoutTab and definePageLayoutWidget.
icon: 'table-columns'
---
A **page layout** controls how a record's detail page is arranged: which tabs appear and what widgets they contain. Use `definePageLayout()` to declare a layout for an object you own, `definePageLayoutTab()` to add a single tab to a layout that already exists (yours or a standard Twenty one), or `definePageLayoutWidget()` to add a single widget to a tab that already exists.
| Use case | Entity |
| ---------------------------------------------------------------------- | --------------------- |
| Define the entire layout for a record page on an object you own | `definePageLayout` |
| Add one tab to an existing layout (your own object, or a standard one) | `definePageLayoutTab` |
| Add one widget to an existing tab (the Home tab of a record page, or a tab you defined) | `definePageLayoutWidget` |
## definePageLayout
Use this when you own the entire detail page — typically for a custom object you defined yourself.
```ts src/page-layouts/example-record-page-layout.ts
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
heightBehavior: 'TAB_VIEWPORT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
```
### Key points
- `type` is one of `'RECORD_INDEX'`, `'RECORD_PAGE'`, `'DASHBOARD'`, `'STANDALONE_PAGE'` or `'RECORD_FORM'`. Use `'RECORD_PAGE'` to customize the detail view of a specific object. `'RECORD_FORM'` describes the creation form of an object and holds `FORM_FIELD` widgets, each rendering a single field. Twenty provisions one `'RECORD_FORM'` layout per object with one widget per creatable field, so you rarely need to author one; it does not render record forms yet.
- `objectUniversalIdentifier` specifies which object this layout applies to.
- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode`: use `VERTICAL_LIST` for record pages and standalone pages, and `GRID` for dashboards. A `VERTICAL_LIST` tab stacks widgets vertically. Built-in widgets that manage their own scrolling, such as timelines, files, notes, tasks, and workflows, fill one viewport by default; fields, front components, graphs, and other fit-content widgets render at their content or configured height. A `GRID` tab always lays its widgets out as cards on a 12-column grid.
- Widgets in a `VERTICAL_LIST` tab inherit the tab's layout mode, and their order comes from the `widgets` array. Do not set `position`; set the optional top-level `heightBehavior` to `'TAB_VIEWPORT'` when a widget should occupy one full tab viewport and own its scrolling, or to `'FIT_CONTENT'` to override a built-in full-height default. Preceding widgets do not reduce that height. Only one active `TAB_VIEWPORT` widget is allowed per tab, and it must come after fit-content widgets.
- `GRID` widgets continue to use `position: { layoutMode: PageLayoutTabLayoutMode.GRID, row, column, rowSpan, columnSpan }`. Top-level `heightBehavior` is only valid for `VERTICAL_LIST` tabs; using it with `GRID` or `CANVAS` is a validation error.
- Existing Canvas tabs and explicit widget positions remain supported. The SDK shows deprecation warnings with the recommended replacements.
- Set `layoutMode` explicitly. Omitting it gives you `VERTICAL_LIST` on a `STANDALONE_PAGE` and `GRID` everywhere else, which is rarely what you want on a record page.
- Each `widget` inside a tab can render a [front component](/developers/extend/apps/layout/front-components), a relation list, or other built-in widget types.
- A `FRONT_COMPONENT` widget can set `headerCommandMenuItemUniversalIdentifiers` to an ordered array of command menu item universal identifiers from the same app. These actions appear as icon buttons in the widget card header and keep their command-level availability and permission checks. Identifiers must be unique and must resolve when the app is installed.
- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
- `yarn twenty pull` writes page layouts back with their tabs and widgets inline. A widget the export cannot carry yet, such as one showing your app's own front component or one of the deprecated `VIEW` type, is reported by pull instead of written; see [what pull does not write](/developers/extend/apps/operations/sync-and-recovery#pulling-an-installed-app-back-to-source).
### Field widgets
A `FIELD` widget renders one field of the current record. For relation fields, it can also embed a view of records related to the current record.
To embed a view that is not scoped to the current record, use a [record table widget](#record-table-widgets).
```ts
{
universalIdentifier: 'c1c2c3c4-c5c6-4000-8000-000000000003',
title: 'People → Opportunities',
type: 'FIELD',
configuration: {
configurationType: 'FIELD',
fieldMetadataId: PEOPLE_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'TABLE',
nestedRelationFieldMetadataId: OPPORTUNITIES_FIELD_UNIVERSAL_IDENTIFIER,
},
}
```
- `fieldMetadataId` takes the universal identifier of a field on the layout's object.
- `fieldDisplayMode` is one of `'FIELD'`, `'CARD'`, `'EDITOR'`, `'VIEW'` or `'TABLE'`. `TABLE` embeds a view listing the records of a one-to-many relation field.
- `isUIEditable` applies to relation fields in `TABLE` display mode. It defaults to `true`; set it to `false` to prevent editing records from the embedded table or kanban.
- `nestedRelationFieldMetadataId` is optional and takes the universal identifier of a one-to-many relation field on the relation target object, to list records two relation hops away (e.g. a Company page listing the opportunities of the company's people, or a Person page listing the opportunities of the person's company). The first hop can be a one-to-many or a many-to-one relation field, the second must be one-to-many (junction relations are not supported), and it requires `fieldDisplayMode: 'TABLE'` — combining it with any other display mode is a validation error, since a nested widget always renders as an embedded view.
### Record table widgets
A `RECORD_TABLE` widget embeds a saved view on a dashboard, record page, or standalone page. The saved view determines the records, fields, filters, sorts, and layout shown in the widget.
```ts
{
universalIdentifier: 'd1d2d3d4-d5d6-4000-8000-000000000004',
title: 'Open opportunities',
type: 'RECORD_TABLE',
configuration: {
configurationType: 'RECORD_TABLE',
viewUniversalIdentifier: OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
recordLimit: 8,
isUIEditable: false,
},
}
```
- `viewUniversalIdentifier` identifies the view to display.
- `recordLimit` is optional and caps how many records the widget displays.
- `isUIEditable` applies to table and kanban views. It enables table cell editing or kanban card editing, moving, and creation. It defaults to `true` on `RECORD_PAGE` layouts and `false` on other layouts.
- `isUIEditable` controls UI actions only. Record permissions still apply, and view settings remain editable only in the page layout editor.
Workspace admins can configure the same behavior with **Allow editing** in the [dashboard widget](/user-guide/dashboards/capabilities/widgets#editing-records) and [record page](/user-guide/layout/capabilities/record-pages#editing-related-records) editors.
## definePageLayoutTab
Use this when you only want to **add** a tab to an existing layout — for example, an analytics tab on the standard Company page, or an AI summary tab attached to your own object's layout.
```ts src/page-layouts/example-extra-tab.ts
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
});
```
### Key points
- `pageLayoutUniversalIdentifier` is **required** and must point to a page layout that already exists at install time — either a standard Twenty layout or one defined by your own app. Cross-app references to layouts owned by another installed app are not supported today. When the parent layout is missing, installation fails with a clear validation error.
- For standard Twenty layouts, import identifiers from `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
Each layout entry also exposes its `tabs` and their `widgets`, so you can reference any level:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home
.universalIdentifier;
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets
.fields.universalIdentifier;
```
A short alias `STANDARD_PAGE_LAYOUT` is also available:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
- `widgets` are scoped to this tab only — they reference [front components](/developers/extend/apps/layout/front-components), views, etc. exactly like widgets defined inline in `definePageLayout`.
- `position` controls ordering against existing tabs on the targeted layout. Pick a value that places your tab where you want it relative to built-in tabs.
- Use this instead of `definePageLayout` when you only want to add to an existing layout. Use `definePageLayout` when you own the entire layout.
- `yarn twenty pull` writes a tab you added to a page layout you do not own back as a `definePageLayoutTab()` file, with its widgets inline, under the same limits as widgets of a `definePageLayout()`.
## definePageLayoutWidget
Use this when you only want to **add** a widget to a tab that already exists — for example, a front component on the Home tab of the standard Company page, or a chart on the Home tab of your own object's record page.
```ts src/page-layout-widgets/example-home-widget.ts
import {
definePageLayoutWidget,
PageLayoutTabLayoutMode,
PageLayoutWidgetVerticalListHeightBehavior,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayoutWidget({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000003',
pageLayoutTabUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.tabs.home
.universalIdentifier,
title: 'Hello World',
type: 'FRONT_COMPONENT',
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 1000,
heightBehavior: PageLayoutWidgetVerticalListHeightBehavior.TAB_VIEWPORT,
},
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
});
```
### Key points
- `pageLayoutTabUniversalIdentifier` is **required** and must point to a tab that already exists at install time: a standard Twenty tab (`STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.<object>RecordPage.tabs.<tab>.universalIdentifier`), a tab of one of your own layouts, or a tab of the record page Twenty generates for one of your objects. The generated tabs have deterministic identifiers, so you can compute them:
```ts
import {
getSystemPageLayoutTabUniversalIdentifier,
getSystemRecordPageLayoutUniversalIdentifier,
} from 'twenty-sdk/define';
const EXAMPLE_HOME_TAB_UNIVERSAL_IDENTIFIER =
getSystemPageLayoutTabUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
pageLayoutUniversalIdentifier: getSystemRecordPageLayoutUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
}),
title: 'Home',
});
```
- `position` is **required**, since the tab is not part of your manifest and its layout mode cannot be inferred. Match the tab's layout mode: record page tabs are vertical lists, so use `{ layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, index }` with an index above the built-in widgets (the Fields widget sits at 0).
- For a vertical list, set `position.heightBehavior` to `PageLayoutWidgetVerticalListHeightBehavior.TAB_VIEWPORT` to fill one tab viewport, or `PageLayoutWidgetVerticalListHeightBehavior.FIT_CONTENT` to use the widget's content or configured height. Import this enum from `twenty-sdk/define`, as in the example above. If omitted, the widget keeps its built-in default. Only one active widget per tab can fill the viewport, and its index must place it after all fit-content widgets. `heightBehavior` is not supported in `GRID` or `CANVAS` positions.
- `configuration` works exactly like a widget defined inline in `definePageLayout` or `definePageLayoutTab`: it references [front components](/developers/extend/apps/layout/front-components), views, fields and so on.
- Use this instead of `definePageLayoutTab` when the tab already exists and you only want to place one widget on it. Use `definePageLayoutTab` when you want a tab of your own.
- `yarn twenty pull` writes a widget you added to a tab you do not own back as a `definePageLayoutWidget()` file, under the same limits as widgets of a `definePageLayout()`.

View File

@@ -0,0 +1,123 @@
---
title: Views
description: Ship pre-configured saved views — column order, filters, groups — for objects in your app.
icon: 'list'
---
A **view** is a saved configuration for how records of an object are displayed: which fields appear, their order, whether they're visible, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app.
Every object automatically gets its main list view (the "All ..." table an `OBJECT` navigation item opens) provisioned by the server — including the objects your app creates, with one column per field your app declares. Views you define with `defineView()` are always **additional** views on top of that default one.
```ts src/views/example-view.ts
import { defineView } from 'twenty-sdk/define';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
export default defineView({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'All example items',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconTable',
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. It can be a custom object you defined or a standard Twenty object.
- The object's main list view is server-owned: `key` is deprecated and ignored, so a manifest view can never claim it. Ship a `VIEW` navigation item if you want your view one click away in the sidebar.
- `fields` controls which columns appear and in what order. Each field references a `fieldMetadataUniversalIdentifier`. To reference an auto-created system field such as `createdAt`, see [Targeting System Metadata](/developers/extend/apps/data/system-fields).
- You can also declare `filters`, `filterGroups`, `sorts`, `groups`, and `fieldGroups` for advanced configurations.
- `position` controls ordering when multiple views exist for the same object.
## Optional properties
| Property | Values | Description |
|----------|--------|-------------|
| `type` | `ViewType.TABLE` (default), `ViewType.LIST`, `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, `LIST_WIDGET`, and `CALENDAR_WIDGET` also exist but are used internally by page-layout widgets.) |
| `visibility` | `ViewVisibility.WORKSPACE` (default), `ViewVisibility.UNLISTED` | Whether the view is listed for the whole workspace or hidden from pickers. |
| `openRecordIn` | deprecated | No longer read: where records open is now a property of the [object](/developers/extend/apps/data/objects) (`openRecordIn` on `defineObject()`), falling back to each member's own preference. |
| `sorts` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | Default sort order. |
| `isCompact` | `boolean` | Compact row display. |
| `mainGroupByFieldMetadataUniversalIdentifier` + `shouldHideEmptyGroups` | — | Group records (e.g. kanban columns) by a field. |
| `kanbanAggregateOperation`, `kanbanAggregateOperationFieldMetadataUniversalIdentifier`, `kanbanColumnWidth` | `AggregateOperations.*` | Kanban column aggregates and sizing. |
| `calendarLayout`, `calendarFieldMetadataUniversalIdentifier`, `calendarEndFieldMetadataUniversalIdentifier` | `ViewCalendarLayout.DAY` / `WEEK` / `MONTH` | Calendar views: layout, the date field that positions records, and an optional end date field. |
All enums above are exported from `twenty-sdk/define`.
## Filters
A view can ship with pre-applied filters. Each filter has three coordinates: the **field** being filtered, the **operand** (how to compare), and the **value** (what to compare against). All three must line up — using an operand that doesn't apply to a field type will be rejected at sync time.
```ts
import { ViewFilterOperand } from 'twenty-sdk/define';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
Three optional properties refine a filter:
| Property | Description |
|----------|-------------|
| `subFieldName` | Targets one part of a composite field, such as `amountMicros` on a `CURRENCY` field or `primaryLinkUrl` on a `LINKS` field. |
| `relationTargetFieldMetadataUniversalIdentifier` | On a filter over a `RELATION` field, the field of the related object the value is compared against, as the advanced filter sets it when you pick a field of the related record. Leave it out to filter on the related record itself. |
| `viewFilterGroupUniversalIdentifier` + `positionInViewFilterGroup` | Place the filter in one of the view's `filterGroups` (each with a `logicalOperator` of `AND`, `OR` or `NOT` and an optional `parentViewFilterGroupUniversalIdentifier`) and order it within that group. |
`yarn twenty pull` writes all three back exactly as the workspace stored them.
### Supported operands per field type
| Field type | Supported operands |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
### Value shape per operand
The `value` field is always a JSON-serializable value, but its expected shape depends on the operand:
| Operand family | Value shape | Example |
| ----------------------------------------------------- | ------------------------------ | ------------------------ |
| `IS`, `IS_NOT` on `SELECT` | array of option keys (strings) | `['ACTIVE', 'PENDING']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` on `MULTI_SELECT` | array of option keys (strings) | `['TAG_A']` |
| `IS`, `IS_NOT` on `RELATION` | array of record IDs (uuids) | `['c5a1...']` |
| `CONTAINS`, `DOES_NOT_CONTAIN` on text-like fields | string | `'acme'` |
| `IS`, `IS_NOT` on `NUMBER` | string (the value) | `'5'` |
| `IS` on `RATING` / `UUID` | string (the value) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | string (the bound) | `'10'` |
| `IS`, `IS_BEFORE`, `IS_AFTER` on `DATE` / `DATE_TIME` | ISO 8601 string | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | empty string | `''` |
| `IS` on `BOOLEAN` | `'true'` or `'false'` | `'true'` |
## How views show up in the UI
A view by itself isn't reachable from the sidebar. To make it appear there, pair it with a [navigation menu item](/developers/extend/apps/layout/navigation-menu-items) of type `VIEW` that points at the view's `universalIdentifier`. That's the canonical pattern: every custom object gets a default index view auto-generated at creation, so you only need to ship the sidebar entry that opens it.

View File

@@ -0,0 +1,247 @@
---
title: Background Jobs
description: Hand long or rate-limited work to the Twenty workers by enqueuing another logic function run instead of doing everything inline.
icon: "layer-group"
---
A logic function run is capped by its `timeoutSeconds` (900 seconds maximum). Anything that can't finish in that window — a full re-sync, a per-record fan-out, a third-party API that rate-limits you — has to be split into smaller runs.
`enqueueJobs` does exactly that: it asks the Twenty workers to run one of your app's logic functions later, once per payload, each run in its own process with its own timeout budget. The caller returns immediately.
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## Enqueue runs
Import `enqueueJobs` from `twenty-sdk/logic-function`, point it at the `universalIdentifier` of the logic function to run, and pass one payload per run to enqueue.
```ts src/logic-functions/sync-all-contacts.ts
import { enqueueJobs } from 'twenty-sdk/logic-function';
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
jobs: [{ payload: { page: 1 } }],
});
```
Each run receives its payload as the handler argument, exactly like any other trigger. The target must belong to the **same application** as the caller — enqueuing another app's function is rejected with `Logic function not found` and nothing is enqueued. A single call accepts up to `200` jobs.
<Note>
`enqueueJobs` returns as soon as the jobs are accepted, not when they have run. It does not return the targets' results — have each target write what it produces to the [key-value store](/developers/extend/apps/logic/key-value-store) or to a workspace record if you need to read it back. To find out whether a run finished, poll its [job status](#poll-jobs).
</Note>
<Note>
The older `enqueueJob` helper, which enqueues a single job per call, is deprecated. Use `enqueueJobs` with a one-element `jobs` list instead. The `payloads` argument is deprecated too — pass `jobs` instead, which takes the same payload per run and lets you name each one.
</Note>
## Poll jobs
`enqueueJobs` returns one `jobId` per enqueued run, in the same order as the jobs you passed. `getJobs` reads back where those runs got to, in one call.
```ts
import { enqueueJobs, getJobs } from 'twenty-sdk/logic-function';
const { jobIds } = await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
jobs: [{ payload: { companyId: 'abc' } }],
});
const statuses = await getJobs(jobIds);
```
A single `getJobs` call reads at most `200` ids, the same ceiling `enqueueJobs` applies to a batch.
Each entry carries the id you asked for:
| Field | What it holds |
|-------|---------------|
| `jobId` | The id you polled with. |
| `state` | `WAITING`, `PRIORITIZED`, `DELAYED`, `ACTIVE`, `WAITING_CHILDREN`, `COMPLETED` or `FAILED`. |
| `attemptsMade` | Queue attempts made so far, including the current one. |
| `failedReason` | The error message of the last failed attempt, otherwise `null`. |
| `enqueuedAt` | When the job was accepted, in epoch milliseconds. |
| `startedAt` | When a worker picked it up, `null` while it is still waiting. |
| `finishedAt` | When it completed or failed, `null` while it is still running. |
A job that has not been picked up yet normally reads as `PRIORITIZED` rather than `WAITING`, because application jobs are queued at a fixed low priority. `COMPLETED` and `FAILED` are the terminal states. A poll loop should also give up on its own after a bounded number of attempts rather than waiting forever, since a run is only readable for as long as the queue retains it: 4 hours after completing, 7 days after failing, and in both cases only the most recent 1000 jobs in that state.
`getJobs` returns only the jobs it still holds, so an id that has been evicted — or never existed — is simply absent from the result rather than erroring the whole batch. Compare what you get back against what you asked for to spot those, and treat a missing id as "no longer known", which is not the same answer as "it never ran".
Statuses are scoped to the workspace the call is made from, so one workspace can never read another workspace's job.
## Choose your own job id
Pass `jobs` instead of `payloads` to name each run yourself. The id you pass is the one `getJobs` takes, so a caller can poll a job without having to store what `enqueueJobs` returned.
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
jobs: [{ payload: { companyId: 'abc' }, jobId: 'enrich-abc' }],
});
```
An id is at most 128 characters of letters, digits, `_`, `.` or `-`, and no two jobs in one call may share one. Pass either `payloads` or `jobs`, never both. When you omit `jobId`, Twenty generates one.
A `jobId` is also an idempotency key: enqueuing an id that the queue still holds is accepted but does **not** start a second run, and `getJobs` keeps reporting the first one. This makes a retried enqueue safe. Two caveats: the guarantee only lasts as long as the job is retained, so the same id can start a fresh run once the original has been evicted, and ids are shared by every app in a workspace, so prefix yours if a collision would matter.
## Job options
Options apply to every run in the batch.
| Option | Default | Range | What it does |
|--------|---------|-------|--------------|
| `retryLimit` | `0` | `0`–`10` | Overall extra queue attempts. Application-requested retries are capped at `3`. Only raise this for handlers that are safe to run twice. |
| `delayMs` | `0` | `0`–`604800000` (7 days) | Wait this long before the runs become eligible. |
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
jobs: [{ payload: { page: 1 } }],
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**Priority is not configurable yet.** Enqueued jobs always run at the lowest priority, so platform work is never delayed behind application jobs. Control over priority is coming soon.
</Note>
The queued run inherits the acting user of the function that enqueued it, so it acts with the same permissions.
## Retry a transient failure
Twenty does not retry every exception from application code. An ordinary thrown error is treated as a permanent failure. For a transient failure, a queued logic function can request up to three retries by throwing `RetryableLogicFunctionError`.
```ts
import {
type LogicFunctionExecutionContext,
RetryableLogicFunctionError,
} from 'twenty-sdk/logic-function';
export const handler = async (
_payload: unknown,
{ retryCount, maxRetries }: LogicFunctionExecutionContext,
) => {
const response = await fetch('https://api.example.com/contacts');
if (response.status === 429 || response.status >= 500) {
throw new RetryableLogicFunctionError(
`The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
);
}
};
```
Throw `RetryableLogicFunctionError` directly when possible. If you extend it, do not replace its `name`: Twenty recognizes the serialized name `RetryableLogicFunctionError` across execution runtimes.
`retryCount` is `0` for the initial execution and increases only when application code requests a retry. `maxRetries` is at most `3` and can be lower when the queued job has a smaller overall retry limit. Platform failures do not increase `retryCount`, although they still consume the queue's overall safety budget.
The queue delays retry attempts with exponential backoff and jitter. The exact delay is intentionally not guaranteed, so application code should not depend on a retry happening at a precise time. Once `maxRetries` is reached, another `RetryableLogicFunctionError` is recorded as the final application failure without another execution.
<Warning>
Retries re-run the whole handler and may happen after some side effects succeeded. Make the handler idempotent before requesting retries.
</Warning>
## Use it: page through a long sync
The classic shape is a function that enqueues *itself* with the next cursor. Each run does one page of work well inside its own timeout, and the chain stops when there is nothing left.
```ts src/logic-functions/sync-contacts-page.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs } from 'twenty-sdk/logic-function';
const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';
const handler = async (params: { cursor?: string }) => {
const { contacts, nextCursor } = await fetchContactsPage(params.cursor);
await importContacts(contacts);
if (nextCursor) {
await enqueueJobs({
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
jobs: [{ payload: { cursor: nextCursor } }],
delayMs: 2_000,
});
}
return { imported: contacts.length, done: !nextCursor };
};
export default defineLogicFunction({
universalIdentifier: SYNC_CONTACTS_PAGE,
name: 'sync-contacts-page',
timeoutSeconds: 120,
handler,
});
```
## Fan out per record
When the work is naturally per-item, enqueue one job per item in a single call and let the workers process them in parallel instead of looping inline.
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
jobs: companies.map((company) => ({ payload: { companyId: company.id } })),
retryLimit: 2,
});
```
## Good practice for long-running work
Two rules cover almost every long job: **recurse instead of looping**, and **process a bounded chunk per run**.
A run that tries to do everything is the failure mode — it hits the timeout, and with a retry it starts the whole thing again from zero. Instead, size one chunk so it comfortably finishes inside `timeoutSeconds`, persist your position, and enqueue the next run.
```ts src/logic-functions/enrich-companies-batch.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs, kv } from 'twenty-sdk/logic-function';
const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
const CHUNK_SIZE = 50;
const handler = async (params: { offset?: number }) => {
const offset = params.offset ?? 0;
const companies = await listCompaniesToEnrich({
offset,
limit: CHUNK_SIZE,
});
for (const company of companies) {
await enrichCompany(company);
}
await kv.set('enrich:progress', { offset: offset + companies.length });
if (companies.length === CHUNK_SIZE) {
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
jobs: [{ payload: { offset: offset + CHUNK_SIZE } }],
});
}
return { processed: companies.length, done: companies.length < CHUNK_SIZE };
};
export default defineLogicFunction({
universalIdentifier: ENRICH_COMPANIES_BATCH,
name: 'enrich-companies-batch',
timeoutSeconds: 300,
handler,
});
```
What makes this hold up:
- **Size the chunk from the slowest item, not the average.** `CHUNK_SIZE × worst-case item time` has to fit in `timeoutSeconds` with room to spare, or the tail of a chunk is lost when the run is cut off.
- **Make the terminating condition explicit.** Recurse only while a full chunk came back. A chain that stops on "no results" alone will keep going forever if the source ever returns a short page mid-way.
- **Persist progress before enqueuing the next run,** so a failed link restarts from the last completed chunk instead of the beginning.
- **Keep each chunk idempotent.** Reprocessing one chunk after a retry must not double-write — key writes on the record or external id you are processing.
- **Prefer a chunked chain over one giant fan-out** when the work hits a rate-limited third party: a chain with `delayMs` paces itself, whereas thousands of jobs enqueued at once all become eligible immediately.

View File

@@ -0,0 +1,297 @@
---
title: Connections
description: Let your app act on a user's behalf in third-party services via OAuth.
icon: 'plug'
---
Connections are credentials a user holds for an external service (Linear, GitHub, Slack, ...). Your app declares **how** those credentials are obtained — a **connection provider** — and consumes them at runtime to make authenticated calls to the third-party API.
Today only OAuth 2.0 is supported. Future credential types (personal access tokens, API keys, basic auth) will plug into the same surface — apps already using `defineConnectionProvider({ type: 'oauth', ... })` won't need to migrate.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="Declare how your app's connections are obtained">
A connection provider describes the OAuth handshake your app needs. The user clicks "Add connection" in your app's settings, completes the provider's consent screen, and a `ConnectedAccount` row is created in their workspace.
A working setup needs **two files** — the connection provider, and a matching `serverVariables` declaration on `defineApplication` that holds the OAuth client credentials.
```ts src/connection-providers/linear-connection.ts
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
// Optional: a logo bundled in your app's public/ folder, shown next to the
// provider in the per-app settings tab.
logo: 'public/linear-logomark.svg',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});
```
```ts src/application.config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});
```
Key points:
- `name` is the unique identifier string used in `listConnections({ providerName })` (kebab-case, must match `^[a-z][a-z0-9-]*$`).
- `displayName` shows in the per-app settings tab and in the AI tool list.
- `logo` is a path to an asset bundled in your app's `public/` folder, served through the app's public assets. When omitted, the settings tab falls back to a placeholder built from `displayName`.
- `clientIdVariable` / `clientSecretVariable` are **names**, not values — they must match keys declared in `defineApplication.serverVariables`. The actual `client_id` and `client_secret` are entered by the server admin through the app registration UI, never committed to your repo.
- Use `serverVariables` (not `applicationVariables`) — OAuth credentials are server-wide and one OAuth app per Twenty server.
- Until both `serverVariables` are filled in, the per-app settings tab shows a "needs server admin" hint and the "Add connection" button is disabled.
- `type: 'oauth'` is the only supported value today. The discriminator is forward-compatible: future types (`'pat'`, `'api-key'`, ...) will add new sub-config blocks alongside `oauth`.
The OAuth callback URL your provider needs to whitelist is:
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="Run a logic function on connect" description="React the moment a connection is established">
Some providers hand you data at connect time that you need to persist before the connection is usable — the classic example is Slack, where the OAuth response identifies the workspace's `team_id` that inbound events will be keyed by. Set `onConnectLogicFunction` to reference a logic function in the same app (by its `universalIdentifier`), and it runs right after the `ConnectedAccount` is created.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});
```
The hook runs **asynchronously in the connecting workspace** (it is enqueued, not awaited), so a slow or failing hook never blocks or breaks the OAuth callback — make it idempotent and handle its own retries. The handler receives:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
From there use `getConnection(connectedAccountId)` to read the fresh access token and call the provider's API (e.g. Slack `auth.test`) or persist a mapping with the [key-value store](/developers/extend/apps/logic/key-value-store).
</Accordion>
<Accordion title="Run a logic function on disconnect" description="Clean up when a connection is removed">
Anything an app claims at connect time has to be released when the connection goes away. A Slack integration that claims a `team_id` on connect, for instance, has to release that claim so another workspace can connect the same Slack team. Set `onDisconnectLogicFunction` to reference a logic function in the same app, and it runs right before the `ConnectedAccount` and its token are deleted, so the handler can still call the provider with `getConnection` to release anything the on-connect hook set up remotely.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});
```
Unlike the on-connect hook it runs **inline in the disconnecting workspace**, once, without retries: a failure is captured and the disconnect still completes. The handler receives the same payload shape:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
The `ConnectedAccount` and its token are still present when the hook runs, so `getConnection(connectedAccountId)` resolves with a fresh access token and the handler can delete whatever it registered remotely. State the provider cannot give back (a `team_id`, an external subscription id) still belongs in the [key-value store](/developers/extend/apps/logic/key-value-store), written at connect time and keyed by `connectedAccountId`. Keep the handler short: it runs inside the disconnect request, so its `timeoutSeconds` is the upper bound on how long that request can take.
The hook fires when a connection is removed on its own. Uninstalling the app drops its connections through a database cascade instead, so the hook does not run there. Declare an `uninstallLogicFunction` on `defineApplication` for that path: it runs before the app's metadata is deleted, so it can still call `listConnections` and clean up whatever is left.
</Accordion>
<Accordion title="listConnections / getConnection" description="Use connections from a logic function">
Inside a logic function handler, `listConnections({ providerName })` returns this app's `ConnectedAccount` rows for the given provider, with refreshed access tokens.
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};
```
Each connection has:
| Field | Description |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Unique row id; pass to `getConnection(id)` to refetch a single one |
| `visibility` | `'user'` (private to one workspace member) or `'workspace'` (shared with all members) |
| `scopes` | OAuth permissions granted by the upstream provider (distinct from `visibility` — those are unrelated) |
| `userWorkspaceId` | The owner's userWorkspace id — useful for picking "the request user's connection" in HTTP-route triggers |
| `workspaceMemberId` | The owner's workspace member id, or `null` if they've since left the workspace |
| `accessToken` | Fresh OAuth access token (refreshed automatically if expired) |
| `name` | The connection's display name (auto-derived at OAuth callback, user-renameable) |
| `handle` | The connected upstream account's email, refreshed on each reconnect; resolved from the OIDC `id_token` returned at token exchange, falling back to the connecting Twenty user's email when the provider doesn't return one |
| `authFailedAt` | Set when the most recent refresh failed, or when the app reported the credential dead via `reportConnectionAuthFailure`; the user must reconnect |
| `authFailedReason` | Human-readable explanation for `authFailedAt`, when the reporter gave one; shown on the connection detail page in settings |
Key points:
- Pass `{ providerName }` to filter by provider; omit it to get all connections this app owns across all providers.
- The server transparently refreshes the access token before returning. Your handler always sees a usable token (or `authFailedAt` set).
- `getConnection(id)` is the single-row equivalent.
</Accordion>
<Accordion title="reportConnectionAuthFailure" description="Tell the platform a credential is dead">
The platform sets `authFailedAt` on its own only when an OAuth refresh fails. Providers whose tokens are never refreshed (a Slack bot token, for example) fail only at call time inside your handlers, so the connection row in settings would keep showing **Connected** while every call dies. When your code hits a definitive auth rejection, report it:
```ts
import { reportConnectionAuthFailure } from 'twenty-sdk/logic-function';
await reportConnectionAuthFailure({
connectionId: connection.id,
reason:
'Slack rejected the stored token (invalid_auth). Reconnect to restore the integration.',
});
```
This sets `authFailedAt` and the optional reason on the connection. The settings row flips to **Reconnect needed** with a Reconnect button, the connection detail page shows the reason, and `getConnection` starts throwing `AppConnectionAuthFailedError` for that row. The flag and reason clear automatically when the user reconnects. A `reason` longer than 1000 characters is rejected, so truncate provider payloads before passing them.
Only report failures that a reconnect would actually fix (an `invalid_auth`-class rejection from the provider), never transient network errors. An app can only report its own connections, and a request user can only report their own user-visibility credentials.
The mutation behind this helper ships with Twenty 2.40.0, so calling it against an older server fails. If your app uses it, declare the dependency in your `package.json`: `"engines": { "twenty": ">=2.40.0" }`.
</Accordion>
<Accordion title="Per-user vs workspace-shared visibility" description="How users choose between private and shared credentials">
When a user clicks "Add connection," they're prompted to pick a visibility:
- **Just for me** — the credential is private to the connecting user. Any logic function called on their behalf (HTTP-route trigger with `isAuthRequired: true`) sees it; cron triggers and database events do not.
- **Workspace shared** — any workspace member can use the credential. Cron / database triggers also see it, since they have no request user.
Use the right one for each handler:
```ts
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');
```
Multiple connections per (user, provider) are allowed, so the same user can hold "Personal Linear" and "Work Linear" side by side.
</Accordion>
<Accordion title="One-time provider setup" description="Register your OAuth app with the third-party service">
For each connection provider, the server admin needs to register an OAuth app at the third party first.
1. Go to the provider's developer settings (e.g. https://linear.app/settings/api/applications/new).
2. Set the **Redirect URI** to `<SERVER_URL>/auth/apps/callback`.
3. Copy the generated **Client ID** and **Client Secret**.
4. Open the installed app in Twenty as a server admin → set the values on the corresponding `serverVariables`.
5. Workspace members can then add connections from the per-app **Connections** section.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,179 @@
---
title: Charging Credits
description: Declare what your app bills for and charge workspace credits from a logic function.
icon: "coins"
---
<Warning>
Not live yet. Credit charging for apps launches officially in October. This page is published early for partners who want to experiment with it.
</Warning>
Apps that cost money to run — a call recorder paying a transcription provider, an enrichment app paying per match — charge the workspace in **credits**. Everything an app bills for is declared in one `billing` block on your application config:
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
// ...identity, variables...
billing: {
description: '$20/month + $0.02 per minute recorded',
recurring: {
platformFee: {
period: 'MONTH',
amountMicroCredits: 20_000_000,
label: 'Platform fee',
},
},
operations: {
recordMeeting: {
operationType: 'CALL_RECORDING',
label: 'Meeting recording',
},
},
},
});
```
- `recurring` — flat and per-member fees the **platform** raises for you, once per billing period.
- `operations` — what you charge for as you use it, from a logic function.
- `description` — one friendly line shown to buyers on your marketplace listing.
All three land on the same credit meter and the same **Settings > Usage** breakdown, so a workspace sees one bill.
## Recurring charges
A recurring charge is a subscription fee: a flat monthly amount, an amount per workspace member, or both. You declare the amount and the platform charges it. Your app never calls `chargeCredits` for these.
```ts src/application-config.ts
billing: {
recurring: {
platformFee: {
period: 'MONTH',
amountMicroCredits: 20_000_000,
label: 'Platform fee',
},
seat: {
period: 'MONTH',
amountMicroCredits: 5_000_000,
per: 'WORKSPACE_MEMBER',
label: 'Per member',
},
},
},
```
| Field | Description |
|-------|-------------|
| `period` | `MONTH` — the only period today |
| `amountMicroCredits` | Amount in micro-credits (1 USD = 1,000,000), a positive integer up to 100,000,000 ($100). For a `WORKSPACE_MEMBER` charge this is the rate **per member**, not the total |
| `per` | Omit for a flat fee. `WORKSPACE_MEMBER` multiplies the amount by the number of members in the workspace when the period is charged |
| `label` | What admins read for that line in Usage |
How they are raised:
- Once per workspace billing period, on the workspace's own period boundary, not on the first of the month.
- Not prorated. An app installed mid-period is charged the full amount for that period; uninstalling stops the next period, it does not refund the current one.
- Per-member charges count all workspace members at the moment the charge is raised, so a workspace that grows mid-period pays the higher count from the next period.
- Only on monthly subscriptions. A workspace on a yearly plan is not charged a `MONTH` fee, rather than being charged once for the whole year.
- Bounded. A declared rate above $100, or a per-member charge whose rate multiplied by the member count would exceed $1,000 for the period, is refused and not raised. The platform raises these itself, so it will not write a debit it cannot justify.
## Billable operations
An operation is what your app charges for as it works. Each entry maps an operation name you choose onto the billing category the platform meters, plus the label shown to workspace admins in **Settings > Usage**.
```ts src/application-config.ts
billing: {
operations: {
recordMeeting: {
operationType: 'CALL_RECORDING',
label: 'Meeting recording',
},
summarizeMeeting: {
operationType: 'AI_CHAT_TOKEN',
label: 'Meeting summary',
},
},
},
```
`operationType` must be one of the platform's billing categories: `AI_CHAT_TOKEN`, `AI_WORKFLOW_TOKEN`, `WORKFLOW_EXECUTION`, `CODE_EXECUTION`, `WEB_SEARCH`, `CALL_RECORDING`, `EMAIL_SEND`. It decides how the spend is metered and which counting unit applies (tokens, invocations, minutes) — it is not what admins read. The `label` is.
The build rejects an unknown `operationType`, an empty `label`, and a name used by both a recurring charge and an operation.
## Charging
Charge against a declared operation by name:
```ts
import { chargeCredits } from 'twenty-sdk/billing';
await chargeCredits({
operation: 'recordMeeting',
creditsUsedMicro: 250_000,
quantity: 12,
});
```
`creditsUsedMicro` is the amount in micro-credits (1 USD = 1,000,000). `quantity` is how many units the charge covers, in the unit implied by the operation's category — minutes for `CALL_RECORDING`, invocations for `CODE_EXECUTION`, and so on.
Notes:
- Declaring operations is what lets Usage split your app's spend by what it charged for. An app that declares nothing is still reported, as a single slice under its display name.
- Charging an operation your manifest does not declare is rejected. Add it to `billing.operations` and sync.
- Several operations may share one `operationType`. Two enrichment operations both metered as `CODE_EXECUTION` still show as two labelled slices.
- Without `operation`, name the billing category directly with `operationType`, optionally with a free-form `resourceContext`. The two forms are mutually exclusive: `operation` carries the label, `operationType` does not.
- Charging never fails your function. A billing error is logged and swallowed, and the call no-ops outside the logic function runtime so local runs and tests do not crash.
## Checking before you spend
A workspace can run out of credits. Your app is not stopped when that happens, but some of the platform's own work is. `runAgent` returns an error instead of running, and the other AI entry points refuse outright. So an app that does its expensive work first and calls the platform at the end can fail late, on a call it did not write.
Ask first:
```ts
import { getCreditAvailability } from 'twenty-sdk/billing';
const { hasAvailableCredits } = await getCreditAvailability();
if (!hasAvailableCredits) {
return;
}
```
When credits are unavailable a `reason` comes with it: `no-credits`, `no-subscription`, or `workspace-suspended`.
Notes:
- It answers whether the workspace may spend, not how much it has left. Your app does not see the balance or the plan.
- It fails open. If the check times out or the server is unreachable it reports credits available, so an infrastructure hiccup does not stop your app. Treat it as a hint worth honouring, not as an authorization decision.
- It is a snapshot. Credits can run out between the check and the work.
- The gate is not applied uniformly. Workflow runs are not credit-gated, and neither is every outbound email path, so a `false` here tells you the workspace is out of credits rather than predicting exactly which calls will fail.
## Telling people what it costs
Declaring charges tells the platform how to *meter* your app; it does not tell a buyer what they will pay. Set `billing.description` for that — one short, friendly line rendered next to the install button on your marketplace listing, before anyone installs.
```ts src/application-config.ts
billing: {
description: '$0.02 per minute recorded, billed to your Twenty credits',
},
```
It is free text on purpose: real pricing has tiers, minimums and per-unit rates that a single number cannot express. Keep it to a line — it renders in a narrow column.
## Attribution
Spend is attributed to whoever triggered the run, so it shows up in **Usage by User**. Runs with nobody behind them (a webhook, a cron) have no triggering person — pass `userWorkspaceId` if your app knows who the spend belongs to. It must belong to the workspace the app is installed in, and it is ignored when the run does have a triggering user.
```ts
await chargeCredits({
operation: 'recordMeeting',
creditsUsedMicro: 250_000,
quantity: 12,
userWorkspaceId: ownerUserWorkspaceId,
});
```
Where to get one: `context.userWorkspaceId` on a triggered run, or [`listConnections()`](/developers/extend/apps/logic/connections), which returns the `userWorkspaceId` of each connection's owner.
Recurring charges have no triggering person, so they are reported against the app only, never against a member.

View File

@@ -0,0 +1,112 @@
---
title: Key-Value Store
description: Persist intermediate results, cache data, and share state across logic function runs with the built-in application key-value store.
icon: "database"
---
Logic functions run sandboxed in short-lived Node.js processes — once a run finishes, nothing it kept in memory survives. When you need to **remember something between runs** (cache an expensive API response, store a cursor for incremental syncs, debounce work, or hand state from one function to another), persist it in the built-in key-value store.
Every application gets its own isolated namespace: entries are keyed by the authenticated app, so your keys can never collide with — or be read by — another application.
The store is **persistent**: entries are stored in the instance's PostgreSQL database — not in an in-memory cache like Redis — so they survive server restarts, deployments, and app updates, and stay around until you delete them. This makes it reliable for durable state like sync cursors or per-user configuration, not just short-lived caches. Values are stored as plain JSON without application-level encryption, so don't put secrets in it — keep API keys and other credentials in secret [application or server variables](/developers/extend/apps/config/application), which are encrypted at rest.
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## Get, set, delete
Import `kv` from `twenty-sdk/logic-function`. Values can be any JSON-serializable payload.
```ts src/logic-functions/sync-linear-issues.ts
import { kv } from 'twenty-sdk/logic-function';
// Read a value. Returns null when the key is missing.
const cursor = await kv.get<string>('sync-cursor:linear');
// Write a value. Creates the entry on first write, updates it afterwards.
await kv.set('sync-cursor:linear', newCursor);
// Delete an entry. Returns true when an entry was removed.
await kv.delete('sync-cursor:linear');
```
## Scopes
Each entry has a scope, passed as an option on every call. The default is `WORKSPACE`.
- **`WORKSPACE`** (default) — the entry is private to the current workspace install of your app. Each workspace that installs the app gets its own independent set of keys. This is what you want for caches, cursors, and per-workspace state.
- **`SERVER`** — the entry is shared across **every install** of your app on the server. Server entries behave like **claims**: the stored value is always the workspaceId that claimed the key (omit `value` on `set` to claim the key for the current workspace), and only that workspace can overwrite or delete it. Any install can read the entry.
Server claims exist for cross-workspace routing. A [server-route resolver](/developers/extend/apps/logic/logic-functions#server-route-trigger) runs in the application registration owner workspace, but an inbound webhook usually only carries an external account id — not a Twenty workspaceId. Have each workspace claim its external id at connect time, then resolve it in the route:
```ts
// In the connected workspace, when the external account is linked:
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });
// In the server-route resolver (owner workspace), on each webhook:
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
scope: 'SERVER',
});
```
Because a server key can only be claimed for the caller's own workspace and never overwritten by another one, a workspace can't hijack a mapping that belongs to someone else. `kv.set` throws when the key is already claimed by another workspace.
## Use it: cache an expensive call
A typical use is caching a slow or rate-limited third-party response so repeated runs reuse it instead of paying the cost every time.
```ts src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
const ONE_HOUR_MS = 60 * 60 * 1000;
type CachedRate = { rate: number; fetchedAt: number };
const handler = async (params: { from: string; to: string }) => {
const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
const cached = await kv.get<CachedRate>(cacheKey);
if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
return { rate: cached.rate, cached: true };
}
const response = await fetch(
`https://api.example.com/rate?from=${params.from}&to=${params.to}`,
);
const { rate } = (await response.json()) as { rate: number };
await kv.set(cacheKey, { rate, fetchedAt: Date.now() });
return { rate, cached: false };
};
export default defineLogicFunction({
universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
name: 'get-exchange-rate',
timeoutSeconds: 10,
handler,
});
```
## Patterns & tips
- **Namespacing.** Prefix keys to keep different concerns apart — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
- **Expiry (TTL).** The store has no built-in expiration. Store a timestamp inside the value (as in the cache example) and check it on read, or clear stale keys from a [cron-triggered function](/developers/extend/apps/logic/logic-functions).
- **What to store.** Any JSON-serializable value — numbers, strings, arrays, objects. Keep entries small; this is for coordination and caching, not large blobs or files. For files, use a `FILES` field and [`uploadFile`](/developers/extend/apps/logic/logic-functions#uploading-files).
- **Visibility.** Entries live in the instance's Postgres database, not as workspace records — they never show up in the workspace UI, aren't part of your app's data model, and need no role or object permissions.
## Alternative: a queryable store object
The built-in store is deliberately opaque: entries aren't records, so you can't browse them in the UI, relate them to other objects, or filter them with record queries. When you need any of that — say a visible sync log, or per-record state — define a small **technical object** with a unique `key` field and a `RAW_JSON` `value` field instead, and query it through the [typed API client](/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk). See [Objects](/developers/extend/apps/data/objects) for the `defineObject` reference and [Data → Unique indexes](/developers/extend/apps/data/overview#unique-indexes) for enforcing key uniqueness.
- **Scoping to a record.** Add a [relation](/developers/extend/apps/data/relations) from the store object to the target object rather than encoding the id into the key.
- **Visibility & permissions.** Rows live in the workspace database like any other record, so they're queryable through the API and respect your app's [role](/developers/extend/apps/config/roles). To keep the store out of the main UI, leave it off your [navigation menu](/developers/extend/apps/layout/navigation-menu-items).
<Note>
Unlike the built-in store, a custom object is always scoped to one workspace — it can't share entries across installs the way `SERVER` keys do.
</Note>

View File

@@ -0,0 +1,980 @@
---
title: Logic Functions
description: Define server-side TypeScript functions with HTTP, cron, and database event triggers.
icon: "bolt"
---
Logic functions are server-side TypeScript functions that run on the Twenty platform. They can be triggered by HTTP requests, cron schedules, or database events — and can also be exposed as tools for AI agents.
<AccordionGroup>
<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/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const body = (params.body ?? {}) as { name?: string };
const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world';
const result = await client.mutation({
createPostCard: {
__args: { data: { name } },
id: true,
name: true,
},
});
return result;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'POST',
isAuthRequired: true,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
},*/
/*cronTriggerSettings: {
pattern: '0 0 1 1 *',
},*/
});
```
Available trigger types:
- **httpRoute**: Exposes your function on an HTTP path and method. In app code, prefix the route path with `/s/` when using `RestApiClient`; the deployed URL uses the injected `TWENTY_FUNCTIONS_URL` base (or `<server-url>/s` when it is not set).
<Note>
To invoke a route-triggered logic function from a (headless) front component, see [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
- **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.*`
- **serverRoute**: Exposes a single registration-scoped HTTP route. A **resolver** function (declared with `serverRouteTriggerSettings`) runs in the owner workspace and either returns a sync `Response` or the target workspace AND logic function to enqueue; on the enqueue path the platform acks with `202` and runs that **target** on the worker queue. See [Server route trigger](#server-route-trigger).
<Note>
You can also manually execute a function using the CLI:
```bash filename="Terminal"
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
```
```bash filename="Terminal"
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
You can watch logs with:
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
<Note>
On the cloud, each run consumes workspace credits: a flat charge per invocation plus a per-millisecond charge for the time the function runs, so keeping `timeoutSeconds` tight and code fast keeps costs down. See [Credits](/user-guide/billing/capabilities/credits) for the rates.
</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/logic-function`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
const { method, path } = event.requestContext.http;
return { message: 'Success' };
};
```
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 }` |
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
| `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>
#### Custom HTTP response
By default, returning a plain value from your handler sends it back as a `200` response (JSON for objects, `text/plain` for strings). To control the status code and response headers, return a `Response` from `twenty-sdk/logic-function`:
```ts
import { Response } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
return new Response('<h1>Hello</h1>', {
status: 201,
headers: { 'content-type': 'text/html' },
});
};
```
For security reasons, response headers are restricted to an allow-list. Any header that is not on the list (e.g. `Set-Cookie`, CORS headers such as `Access-Control-Allow-Origin`, or custom `X-*` headers) is silently dropped before the response is sent. The allowed response headers are:
- `content-type`
- `content-language`
- `content-disposition`
- `cache-control`
- `retry-after`
<Note>
The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively.
</Note>
#### Platform error responses
Beyond your handler's own responses, the platform answers route calls directly in some situations: `404` when the route or function does not exist, `403` when the application is stopped, `429` when the execution rate limit is hit, and `422` when the application's production `dependencies` are too large to install — see [dependencies size limits](/developers/extend/apps/getting-started/project-structure#dependencies).
#### Server route trigger
`httpRouteTriggerSettings` exposes a function under `/s/` and resolves the workspace from the request host — which works when each workspace has its own domain. Third-party providers, however, deliver every tenant's events to **one** URL. For that case, use `serverRouteTriggerSettings`.
The trigger has two parts:
1. A **resolver** logic function — declared with `serverRouteTriggerSettings` — runs in your **owner workspace** (the workspace that owns the application registration). It inspects the incoming request and returns either:
- `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — the platform enqueues that target in the resolved workspace and acks with `202 { queued: true }`, or
- a `Response` from `twenty-sdk/logic-function` — the platform echoes that HTTP response **synchronously** and does **not** enqueue a target (use this for challenge handshakes such as Slack `url_verification`).
The resolver is the single point of authorization — the URL only carries the resolver's identifier. **This is the preferred place to verify request signatures**: the resolver runs before any side effect, has access to the original `rawBody` and forwarded headers, and can reject without ever touching the target.
2. A **target** logic function — a regular per-workspace logic function — then runs in the resolved workspace with the payload returned by the resolver (or the original request payload if the resolver didn't transform it). Its return value is **not** observed by the HTTP caller when the resolver chose the enqueue path.
```ts src/logic-functions/resolve-server-route.logic-function.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { defineLogicFunction } from 'twenty-sdk/define';
import { Response, type RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the owner workspace. Verifies the request signature, picks
// which target function should handle the event, and returns the
// workspace + target the platform should dispatch to.
const handler = async (event: RoutePayload) => {
// Fail closed if the secret isn't configured — never fall back to an
// empty key, which would let any caller forge a matching signature.
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
}
const signature = event.headers['x-hub-signature-256'] ?? '';
const expected =
'sha256=' +
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('invalid signature');
}
const body = (event.body ?? {}) as {
challenge?: string;
metadata?: { twentyWorkspaceId?: string };
type?: string;
};
// Handshakes must be answered on this same response, so reply from the
// resolver instead of returning a dispatch target.
if (body.type === 'url_verification') {
return new Response({ challenge: body.challenge });
}
const workspaceId = body.metadata?.twentyWorkspaceId;
if (!workspaceId) {
throw new Error('event is not linked to a workspace');
}
return {
workspaceId,
// Route different event types to different target functions.
targetLogicFunctionUniversalIdentifier:
body.type === 'invoice.paid'
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
};
};
export default defineLogicFunction({
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'resolve-server-route',
handler,
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
},
});
```
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the resolved workspace. The resolver has already authenticated
// the request, so this handler can focus on the actual work.
const handler = async (event: RoutePayload) => {
// ...handle the verified event
return { received: true };
};
export default defineLogicFunction({
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'handle-invoice-paid',
handler,
});
```
The endpoint is reachable at:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
The identifier is the resolver's `universalIdentifier` from your manifest. Register that URL with the provider.
**Answering a verification GET.** Some providers verify an endpoint before they deliver to it, by sending a `GET` carrying a challenge to the same URL they will later `POST` events to — Meta's WhatsApp Cloud API is one. A server route answers `POST` only unless you say otherwise, so declare both methods:
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
The challenge arrives in `event.queryStringParameters`, and returning a `Response` echoes it back to the provider on the same request. A string body is sent as `text/plain`, which is what these providers expect:
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods` replaces the default rather than adding to it, so `['GET']` alone makes the route reject `POST`. Only `GET` and `POST` are supported. Leave it unset unless the provider needs the second verb: a route that declares `GET` will have its resolver executed by any unauthenticated caller, including the crawlers and link unfurlers that send `GET` unprompted. Anything the platform does not have a method for is answered `405` without the resolver ever running.
<Note>
**The application must be claimed and installed on its owner workspace.** Because the resolver runs in the **owner workspace** (the workspace that owns the application registration), a server route trigger only works once the application has been *claimed* — i.e. it has an owner workspace — **and** that application is **installed on the owner workspace**. Until both are true the resolver has nowhere to run, so the route cannot be dispatched. An application that exposes a `serverRouteTriggerSettings` logic function therefore cannot be listed in the marketplace until it is claimed and installed on its owner workspace.
</Note>
**Resolver contract.** The SDK's `LogicFunctionConfig` type enforces this at compile time: as soon as you set `serverRouteTriggerSettings`, your handler is constrained to return either a `Response`, or `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (or a `Promise` of either). On the dispatch path, the `workspaceId` must be a workspace where the target function is installed, otherwise the request is rejected with `404`. A result that matches neither shape — including one whose identifiers are not UUIDs — is rejected with `502`.
| Field | Type | Notes |
|-------|------|-------|
| `workspaceId` | `string` | Workspace UUID where the target will run. |
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` of the logic function to invoke in that workspace. |
| `payload` | `object` (optional) | If set, replaces the request body sent to the target. |
<Warning>
**Signature verification is your responsibility — verify in the resolver.** The platform does not verify request signatures. The resolver is the recommended place to do it: it runs first, with access to `event.rawBody` and the headers you listed in `forwardedRequestHeaders`, and a thrown error (or any non-matching `workspaceId`) stops the dispatch before the target is invoked. If you instead push verification down into the target, the target must be careful not to lose `rawBody` and headers — i.e. the resolver must not return a `payload`. Always verify **before** any side effect and use a constant-time comparison.
</Warning>
For request signatures, most providers sign with HMAC-SHA256; the parts that differ are the header name, the digest encoding, and the signed-payload string. A few examples:
| Provider | Headers to forward | Signed string | Digest |
|----------|--------------------|---------------|--------|
| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (secret is base64 after stripping `whsec_`) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex (prefixed `sha256=`) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (prefixed `v0=`) |
The resolver example above already shows the GitHub HMAC-SHA256 flow — adapt the header name, digest encoding, and signed-payload string per the provider you're integrating.
<Note>
When the resolver returns a dispatch object, the route responds `202 { queued: true }` and the target runs on the worker queue — the caller never observes the target's latency, result, or failures (those are recorded in execution logs). This keeps sender redeliveries from amplifying processing slowdowns, which is what you want for webhook ingestion.
When the caller must read the response body on the same request (challenge handshakes, interactive acknowledgements), return a `Response` from the **resolver** instead. The platform echoes it synchronously and skips the queue; its headers go through the same allow-list as HTTP route responses. Keep the resolver fast — some providers (e.g. Slack) time out in a few seconds. Because the resolver is reachable as a public endpoint, protect it with rate limiting at your edge.
</Note>
#### Database event trigger payload
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record (or a `DatabaseEventBatchPayload` covering several records when `batchMode` is enabled). The payload combines metadata about the source workspace and object with the record-level event.
```ts
import type {
DatabaseEventBatchPayload,
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
The payload includes:
| Property | Description |
|----------|-------------|
| `name` | Event name, such as `person.updated`. |
| `workspaceId` | Workspace where the event happened. |
| `objectMetadata` | Metadata for the object that changed. |
| `recordId` | Id of the changed record. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Actor fields when the event was caused by a workspace user. |
| `properties` | Record data for the event, with `before`, `after`, `diff`, and `updatedFields` depending on the operation. |
| Event | Record data |
|-------|-------------|
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
For soft deletes, `.deleted` follows the update-style shape because the record's `deletedAt` field changes.
For permanent deletes, use `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filters which update events trigger the function.
`event.properties.updatedFields` tells you which fields actually changed on the current event.
</Note>
`databaseEventTriggerSettings` accepts:
| Property | Description |
|----------|-------------|
| `eventName` | Event to listen on, such as `person.updated`. Supports `*` for the object name, the operation, or both. |
| `updatedFields` | Optional list of field names. On `.updated` events, only changes touching one of them trigger the function. |
| `batchMode` | Optional boolean. When `true`, the handler is called once per batch of records instead of once per record. The server decides how many records a batch holds, up to 200. |
Created event example:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Updated event example:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Trigger only on email updates:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Destroyed event example:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Batching database events
A bulk write emits many events at once: importing a mailbox, for instance, can update thousands of records in a single operation, and one call per record floods the queue. Set `batchMode` to receive them together:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
batchMode: true,
},
});
```
The handler then receives a `DatabaseEventBatchPayload`, which carries the same metadata as `DatabaseEventPayload` but replaces the record-level fields with an `events` array:
```ts
type PeopleUpdatedBatch = DatabaseEventBatchPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (batch: PeopleUpdatedBatch) => {
const updatedEmails = batch.events.map(
(event) => event.properties.after.emails?.primaryEmail,
);
return { workspaceId: batch.workspaceId, updatedEmails };
};
```
<Note>
Batch size is not something a handler can rely on. The server decides how many events a call carries, capped at 200, and a batch holds anything from a single event up to that ceiling depending on how many the originating write produced. Write the handler to loop over `events` whatever its length.
</Note>
Events are grouped by the user who caused them before being batched, so every call covers a single actor and `context.workspaceMemberId` stays meaningful. `updatedFields` filtering is applied before batching, so a batch only contains records you asked for.
Without `batchMode`, the handler keeps receiving one `DatabaseEventPayload` per record.
#### Execution context
Every handler receives a second argument describing the run itself, whatever triggered it. Where the first argument changes shape per trigger, this one does not:
```ts
import type { LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
const handler = async (
event: DatabaseEventPayload,
{ workspaceId, workspaceMemberId }: LogicFunctionExecutionContext,
) => {
if (workspaceMemberId === null) {
throw new Error('This function must be triggered by a person');
}
return { reviewedBy: workspaceMemberId, workspaceId };
};
```
| Field | Type | Description |
|-------|------|-------------|
| `workspaceId` | `string` | The workspace the run belongs to. |
| `userWorkspaceId` | `string \| null` | The person who triggered the run, as their membership of this workspace. |
| `workspaceMemberId` | `string \| null` | The same person as a workspace member record, which is what you link records to. |
| `retryCount` | `number` | `0` on the first attempt. See [Background jobs](/developers/extend/apps/logic/background-jobs). |
| `maxRetries` | `number` | Upper bound on application-requested retries. |
`userWorkspaceId` and `workspaceMemberId` are `null` when nobody triggered the run: cron schedules, install hooks and unauthenticated webhooks have no person behind them. They are also `null` when the person has no workspace member record, or theirs has been deleted.
<Note>
**The context tells you who triggered the run.** What the run may *do* is a separate declaration, below.
</Note>
#### Whose access a call uses
Every client — `CoreApiClient`, `MetadataApiClient` and `RestApiClient` — acts as the person who triggered the run: their role intersected with your application's, so the call can never do more than either of you. That is the default, and it means a person can never use your app to exceed their own permissions.
When nobody triggered the run there is no person to act as, so the same client falls back to your application's own access: cron schedules, install hooks and unauthenticated webhooks need no special handling.
Some calls legitimately need the application's own access even when a person *is* behind the run — reading your app's configuration records, or doing work that person could not do themselves. Build a second client for those:
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const asPerson = new CoreApiClient();
const asApplication = new CoreApiClient({ runAs: 'application' });
const handler = async (event: DatabaseEventPayload) => {
// config records only your app may read
const { slackUserLinks } = await asApplication.query({ ... });
// created as the person, so it is attributed and permission-checked
await asPerson.mutation({ createNote: { ... } });
};
```
Build both once, at module scope, and every call site then says which access it uses by which client it calls. `RestApiClient` takes the same option:
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**A run nobody triggered acts as your application.** Cron schedules, install hooks and unauthenticated webhooks have no person behind them, so the default client falls back to your application's own access and keeps working. `runAs: 'application'` is only needed when you want that access in a run a person *did* trigger.
Check `context.workspaceMemberId` when a function behaves differently depending on whether someone is behind it, for instance to attribute a record.
</Note>
The SDK helpers that reach your application's own resources always use its access and ignore `runAs`: the [key-value store](/developers/extend/apps/logic/key-value-store), [connections](/developers/extend/apps/logic/connections), [messaging channels](/developers/extend/apps/logic/messaging-channels), `runAgent`, `getPublicAssetUrl` and [credit charging](/developers/extend/apps/logic/credits).
#### Exposing a function as an AI tool or workflow action
Logic functions can be exposed on two surfaces, each with its own trigger:
- **`toolTriggerSettings`** — makes the function discoverable by Twenty's AI features (chat, MCP, function calling). Uses standard JSON Schema, the format LLMs natively understand.
- **`workflowActionTriggerSettings`** — makes the function appear as a step in the visual workflow builder. Uses Twenty's rich `InputSchema` so the builder can render proper field editors, variable pickers, and labels.
A function can opt into one, the other, or both. They sit alongside `cronTriggerSettings`, `databaseEventTriggerSettings`, and `httpRouteTriggerSettings` — same pattern, same shape.
<Note>
**Relationship to the workflow Code action.** The built-in **Code** action in the workflow builder is itself a logic function — Twenty creates one per Code step and exposes its editor inline. `workflowActionTriggerSettings` is how you turn that one-off, inline code into a **reusable** action: define the function once in your app and it becomes selectable in any workflow, instead of being copy-pasted into each Code step. See the [Code action](/user-guide/workflows/capabilities/workflow-actions#code) in the user guide for the end-user view.
</Note>
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
return { taskId: result.createTask.id };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
toolTriggerSettings: {},
});
```
Key points:
- A function can mix surfaces — declare both `toolTriggerSettings` and `workflowActionTriggerSettings` to expose it in chat AND in the workflow builder.
- `toolTriggerSettings.inputSchema` and `workflowActionTriggerSettings.inputSchema` are both optional. When omitted, the manifest builder infers them from the handler source code (JSON Schema for the AI tool, Twenty's `InputSchema` for the workflow action). Provide one explicitly when you want richer typing — for example, with `FieldMetadataType`-aware fields like `CURRENCY` or `RELATION` for the workflow builder, or with `description` fields the AI agent can read:
```ts
export default defineLogicFunction({
...,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
},
});
```
To declare your parameters **once** and serve both surfaces, define a single JSON Schema (`InputJsonSchema`) and convert it for the workflow action with `jsonSchemaToInputSchema` from `twenty-sdk/logic-function`. `toolTriggerSettings.inputSchema` takes the JSON Schema directly, while `workflowActionTriggerSettings.inputSchema` expects Twenty's `InputSchema`:
```ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
export default defineLogicFunction({
...,
toolTriggerSettings: { inputSchema },
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
},
});
```
##### A complete workflow action example
`workflowActionTriggerSettings` accepts four fields:
| Field | Purpose |
|-------|---------|
| `label` | Name shown for the action in the workflow builder's step picker. Defaults to the function `name`. |
| `icon` | Icon shown next to the action (a `tabler-icons` name, e.g. `IconBuilding`). |
| `inputSchema` | Twenty's rich `InputSchema` — what the builder renders as configurable fields (with variable pickers). Optional; inferred from the handler when omitted. |
| `outputSchema` | Declares the shape the handler returns, so **subsequent steps can map to its output fields**. Optional; without it, the output is exposed as a single opaque value. |
Putting it together — a function exposed as a workflow action, with a declared output so later steps can reference `taskId`:
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
// The keys returned here should match the `outputSchema` properties below.
return { taskId: result.createTask.id, enriched: true };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
outputSchema: [
{
type: 'object',
properties: {
taskId: { type: 'string' },
enriched: { type: 'boolean' },
},
},
],
},
});
```
Once the app is installed, **Enrich Company** appears in the workflow builder's action picker. The builder renders `companyName` and `domain` as input fields (each able to pull values from previous steps), and downstream steps can reference the step's `taskId` and `enriched` outputs.
<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>
</AccordionGroup>
<Note>
**Runtime helpers.** `twenty-sdk/utils` re-exports small runtime helpers so handlers never import from `twenty-shared` directly. For example, `isDefined(value)` returns `false` for both `null` and `undefined` — use it to safely narrow optional handler inputs, which can arrive as `null` at runtime even when typed `T | undefined`:
```ts
import { isDefined } from 'twenty-sdk/utils';
const handler = async (params: { parentMessageId?: string }) => {
if (isDefined(params.parentMessageId)) {
// params.parentMessageId is narrowed to string here
}
};
```
</Note>
<Note>
**Install hooks** — pre-install, post-install, and uninstall handlers — share this runtime but are declared with their own define functions and don't take trigger settings. See [Install Hooks](/developers/extend/apps/config/install-hooks) for `definePreInstallLogicFunction`, `definePostInstallLogicFunction`, and `defineUninstallLogicFunction`.
</Note>
## Create a timeline activity
Use `createTimelineActivity()` to publish an explicit domain event from a logic function. Define the event first as a [timeline activity type](/developers/extend/apps/data/timeline-activity-types), then address the type and objects by their stable universal identifiers:
```ts
import { createTimelineActivity } from 'twenty-sdk/logic-function';
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
happensAt: new Date().toISOString(),
properties: {
deliveryProvider: 'postal-service',
},
});
```
Twenty resolves universal identifiers to the installation-specific metadata IDs, validates that the timeline activity type belongs to the calling app, and snapshots its presentation metadata on the new activity.
The required inputs are `timelineActivityTypeUniversalIdentifier`, `targetObjectUniversalIdentifier`, and `targetRecordId`. You can also provide `happensAt`, `properties`, and `workspaceMemberId`. `happensAt` controls the event's position and displayed time in the timeline; it defaults to the creation time.
To associate another record with the event, provide `linkedRecordId` and `linkedObjectMetadataUniversalIdentifier` together. You can additionally provide `linkedRecordCachedName` as a historical display fallback:
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
The logic function's role needs write permission on the standard `timelineActivity` object. Keep explicit event types unbound from an `action`; an action-bound type already receives automatic audit events and would otherwise produce duplicate rows.
## 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 dev: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,
},
});
// Only on an object whose readability is PRIVATE: choose who can read the created record
const { createContract } = await client.mutation({
createContract: {
__args: {
data: {
name: 'Acme Corp renewal',
},
shareWith: [
{
roleId: '7f0c1b6e-2d4a-4c8e-9b3f-5a6d7e8f9a0b',
accessLevel: 'READ_WRITE',
},
],
},
id: true,
},
});
```
`shareWith` only matters on an object whose readability is `PRIVATE`; every object is `OPEN` by default, and on an `OPEN` object the argument is ignored. On a `PRIVATE` object it lists who gets access to the created record: each entry targets exactly one of `workspaceMemberId`, `roleId` or `everyone: true` with an `accessLevel` of `READ`, `READ_WRITE` or `FULL`. A workspace member creating a record always receives a `FULL` owner grant, so the argument is optional for them; a logic function acting as its application or an API key also receives a `FULL` grant for its role, and on a workspace where record sharing is enabled (`IS_RECORD_SHARING_ENABLED`) such callers, like a system caller, must pass `shareWith` or the create is rejected — while the flag is off a create without `shareWith` grants `FULL` access to everyone instead, so the record stays as open as it is today. Rows written while the flag is off are kept and take effect the moment the flag is turned on. Every entry must name a member or a role of the workspace. A logic function or API key shares through its role, so every holder of that role gets the same access to the records it creates; give a key whose records must stay private to it a role of its own.
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 dev: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. It takes the same `runAs` option as `CoreApiClient` — see [Whose access a call uses](#whose-access-a-call-uses).
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadataClient = new MetadataApiClient();
// List first 10 objects in the workspace
const { objects } = await metadataClient.query({
objects: {
edges: {
node: {
id: true,
nameSingular: true,
namePlural: true,
labelSingular: true,
isCustom: true,
},
},
__args: {
filter: {},
paging: { first: 10 },
},
},
});
```
#### 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 for the default access: a person's role intersected with your application's when someone is behind the run, your application's own role when nobody is. The person is whoever triggered the run in a logic function, whoever is looking at the page in a front component.
- `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — Short-lived key scoped to your application's own role alone. Logic functions only, always injected there, and what `runAs: 'application'` uses.
You do **not** need to pass these to the clients — they read from `process.env` automatically, and [Whose access a call uses](#whose-access-a-call-uses) covers picking between them. Your application's own permissions are determined by the role declared with `defineApplicationRole()` (or referenced via `defaultRoleUniversalIdentifier` in `application-config.ts`); a run acting as a person can never exceed that role, nor theirs.
</Note>

View File

@@ -0,0 +1,215 @@
---
title: Messaging Channels
description: Bring conversations from any provider into Twenty's Message and Message Thread records.
icon: "messages"
---
A **messaging channel** lets your app put conversations from an external provider — LinkedIn, WhatsApp, SMS, an in-app inbox — into the same `Message` and `Message Thread` records Twenty uses for email. Threads you ingest appear on the Person, Company and Opportunity records of the people in them, in the timeline and in search, with no bespoke UI.
Twenty's message model is deliberately channel-neutral: a message has participants, text, a timestamp and a thread; a participant has a `handle` and an optional link to a Person. Nothing in it is email-specific. A channel is what binds a stream of those messages to one account on one provider.
<Note>
**Beta — and possibly not live on your instance yet.** App-owned message channels are new; the API described here may change, and it may not be available on the version of Twenty you are running. Check that `createMessageChannel` exists in your SDK before building on it.
</Note>
## Prerequisites
A channel always belongs to one of your app's [connections](/developers/extend/apps/logic/connections) — the credential it speaks through. You need a connection provider before you can create a channel.
## Creating a channel
Create the channel once per connected account, from the connection provider's `onConnect` hook. That way the channel's lifetime matches the credential's.
```ts src/logic-functions/handlers/linkedin-register-connection-handler.ts
import { createMessageChannel, getConnection } from 'twenty-sdk/logic-function';
import { MessageChannelVisibility } from 'twenty-shared/types';
export const linkedinRegisterConnectionHandler = async (payload: {
connectedAccountId: string;
}) => {
const connection = await getConnection(payload.connectedAccountId);
const profile = await fetchLinkedinProfile(connection.accessToken);
const channel = await createMessageChannel({
connectedAccountId: connection.id,
handle: profile.memberUrn,
displayName: profile.name,
// LinkedIn DMs are personal, so other members see participants and dates
// but not subjects or bodies.
visibility: MessageChannelVisibility.METADATA,
});
return { channelId: channel.id };
};
```
### `handle`
The account's identity **as the provider names it** — a LinkedIn member URN, an E.164 phone number, a workspace-scoped user id. Twenty treats it as opaque. It is what you match inbound payloads against, and what decides whether an ingested message is incoming or outgoing.
Pick the most stable identifier the provider offers. A handle that changes when the user renames themselves will split their history across two channels.
### `visibility`
`visibility` is **required**, with no default, because it decides who in the workspace can read these conversations:
| Value | Other members see |
|---|---|
| `METADATA` | participants and dates only |
| `SUBJECT` | the above, plus the subject |
| `SHARE_EVERYTHING` | the above, plus the message body |
The member who owns the underlying connection always reads their own messages in full, whatever this is set to.
Choose based on how private the provider's messages are, not on what is convenient to display. Personal inboxes (LinkedIn DMs, personal WhatsApp) want `METADATA`; shared ones (a support inbox, a team number) want `SHARE_EVERYTHING`.
<Warning>
`visibility` is the only thing standing between one member's private conversations and the rest of the workspace. Twenty deliberately makes you state it rather than inheriting a default that might over-share.
</Warning>
## Ingesting messages
Once the channel exists, `ingestMessages` writes conversations into it. Threading, de-duplication, channel association and per-message privacy are handled server-side — the same path Twenty's own email import uses.
```ts src/logic-functions/handlers/linkedin-inbound-handler.ts
import { ingestMessages } from 'twenty-sdk/logic-function';
import { MessageParticipantRole } from 'twenty-shared/types';
export const linkedinInboundHandler = async (payload: LinkedinWebhookPayload) => {
const ingested = await ingestMessages({
messageChannelId: payload.channelId,
messages: payload.events.map((event) => ({
externalId: event.messageUrn,
threadExternalId: event.conversationUrn,
text: event.body,
receivedAt: new Date(event.createdAt),
participants: [
{
role: MessageParticipantRole.FROM,
handle: event.sender.memberUrn,
displayName: event.sender.name,
},
...event.recipients.map((recipient) => ({
role: MessageParticipantRole.TO,
handle: recipient.memberUrn,
displayName: recipient.name,
})),
],
})),
});
return { ingested: ingested.length };
};
```
### What the server does for you
**De-duplication.** `externalId` is the key. Ingesting the same one twice creates nothing and changes nothing, so a redelivered webhook is safe to replay and a backfill may overlap a live stream. The returned `messageId` is stable across replays. Calls against one channel are serialised, so a redelivery arriving while its original is still being written waits rather than racing it.
**Threading.** Messages sharing a `threadExternalId` land in one Message Thread, which then appears on the Person, Company and Opportunity records of its participants.
**Direction.** Derived, not declared: the message is outgoing when the `FROM` participant's handle equals the channel's handle, incoming otherwise. Exactly one participant must carry `FROM` — direction, the thread's sender column and the timeline preview all read it, so a message without one is rejected rather than rendering as an empty conversation.
**De-duplication is scoped to the channel.** `externalId` only has to be unique within one channel, which is what most providers actually guarantee — plenty number messages per conversation or per account. The consequence is that two members in the same provider conversation each get their own Message rather than a shared one. That is deliberate: merging them would require every app to guarantee a global namespace it does not control, and getting it wrong would attach one member's body to another member's channel.
**Privacy.** Each message inherits its channel's `visibility`. Nothing you ingest is more visible than the channel you ingest it into.
### Batching
One call is one transaction inside one logic-function timeout, capped at **100 messages**. Page a provider backfill rather than sending it whole:
```ts
for (const page of chunk(allHistoricalMessages, 100)) {
await ingestMessages({ messageChannelId, messages: page });
}
```
A batch is all-or-nothing, and repeating one `externalId` inside a single batch is rejected — de-duplicate before you send. A message body is capped at 256KB.
### Linking participants to People
Twenty matches email participants to People by email address. That cannot match a LinkedIn URN or a phone number, so **an app must say who a participant is** — an unlinked thread does not appear on anyone's record page.
Pass `personId` alongside the handle:
```ts
participants: [
{
role: MessageParticipantRole.FROM,
handle: event.sender.memberUrn,
displayName: event.sender.name,
personId: await resolvePersonIdFromLinkedinUrn(event.sender.memberUrn),
},
]
```
Resolve the id however your provider's identity is stored — against `Person.linkedinLink`, or an identity field your app added with [`defineField`](/developers/extend/apps/data/extending-objects). Unknown ids are rejected up front rather than stored as dangling links.
Once a participant is linked, Twenty builds the thread's targets, and the conversation appears on that Person's record page and on their company's, exactly as an email thread does.
`workspaceMemberId` is also accepted, for a participant who is a member of this workspace rather than a contact. It is **attribution only**: thread targets are built from `personId` alone — the same as for email — so a participant linked only to a workspace member shows in the thread but pulls the conversation onto no record page. Pass `personId` when you want the thread to land somewhere.
<Note>
A late match is not lost. Re-ingesting a message with an identity you have since resolved links the participant that is already there — matched on the message, handle and role, so a display name that changed or was omitted in the meantime does not split it in two. An omitted name keeps the one already stored; a new one replaces it. Sending a different `personId` corrects an earlier link rather than adding a second one.
There is no way to *clear* an identity yet: a null is treated as "leave it alone", not "unlink". Correcting a wrong link means supplying the right one.
</Note>
Participants you leave unlinked are still stored and still render in the thread — they simply do not pull the conversation onto a record.
## Listing and updating channels
```ts
import {
listMessageChannels,
updateMessageChannel,
} from 'twenty-sdk/logic-function';
// Every channel this app owns, across all its connections
const channels = await listMessageChannels();
// Just the ones on a given connection
const forConnection = await listMessageChannels({
connectedAccountId: connection.id,
});
// Rename it, re-scope who can read it, or pause ingestion
await updateMessageChannel({ id: channel.id, displayName: 'Ada (LinkedIn)' });
await updateMessageChannel({ id: channel.id, isSyncEnabled: false });
```
Changing `visibility` through `updateMessageChannel` re-scopes every message already ingested into the channel, not only the ones that follow. Ingesting into a channel with `isSyncEnabled: false` is rejected, so pausing a channel stops the stream without deleting anything.
Your app only ever sees channels created against its own connections. Channels belonging to other apps, and Twenty's own email channels, are invisible to it.
A channel is also only reachable by the person whose connection it speaks through, when that connection is private to them. A run triggered by another member of the workspace cannot see it, change it or ingest into it; a run with nobody behind it — a cron, a webhook, an install hook — acts as the application and reaches every channel the app owns.
## Retiring a channel
Call `deleteMessageChannel(id)` from the connection provider's `onDisconnect` hook so a channel never outlives the credential it speaks through.
```ts src/logic-functions/handlers/linkedin-disconnect-handler.ts
import {
deleteMessageChannel,
listMessageChannels,
} from 'twenty-sdk/logic-function';
export const linkedinDisconnectHandler = async (payload: {
connectedAccountId: string;
}) => {
const channels = await listMessageChannels({
connectedAccountId: payload.connectedAccountId,
});
await Promise.all(channels.map((channel) => deleteMessageChannel(channel.id)));
};
```
Deleting a channel deletes the messages ingested through it, and any thread left with no messages. To stop ingesting while keeping the history, set `isSyncEnabled: false` instead.
## What a channel does not do
- **It is not polled.** Twenty's import pipeline polls mailbox providers on a cron. App channels are push-only: nothing fetches on your behalf, so your app is responsible for getting messages in, via a webhook route or a cron logic function.
- **It does not send.** Outbound delivery is not yet routed to apps; a reply composed in Twenty cannot reach your provider. Until it is, link out to the provider's own UI for replies.
- **It does not resolve contacts.** Twenty matches email participants to People by email address, which cannot match a LinkedIn URN, so app channels [link participants explicitly at ingestion](#linking-participants-to-people) instead.
- **It does not create contacts.** Twenty's auto-creation derives people and companies from email domains, which is meaningless for most providers, so it is off for app channels. Create the Person yourself, then pass its `personId`.

View File

@@ -0,0 +1,67 @@
---
title: Overview
description: Server-side TypeScript that runs inside Twenty — triggered by HTTP routes, cron schedules, database events, AI tools, or workflow actions.
icon: "bolt"
---
A Twenty app's **logic layer** is the code that *runs* — server-side TypeScript handlers reacting to HTTP requests, cron schedules, and record changes; AI skills and agents that live inside the workspace; and OAuth connections that let your functions act on a user's behalf in third-party services.
```text
┌─ HTTP route ──┐
│ Cron schedule │
│ Database event │ ┌────────────────────┐
triggers ─┤ AI tool call ├─────▶│ Logic function │
│ Workflow action │ │ (your handler) │
│ Manual exec │ └────────────────────┘
└────────────────────┘ │
▼
┌────────────────────────────┐
│ Twenty API (records) │
│ Third-party API │
│ (via Connection token) │
└────────────────────────────┘
```
## In this section
<CardGroup cols={2}>
<Card title="Logic Functions" icon="bolt" href="/developers/extend/apps/logic/logic-functions">
The core building block — trigger types, payloads, and the typed API client.
</Card>
<Card title="Skills & Agents" icon="robot" href="/developers/extend/apps/logic/skills-and-agents">
Reusable AI agent instructions and assistants with custom system prompts.
</Card>
<Card title="Connections" icon="plug" href="/developers/extend/apps/logic/connections">
OAuth credentials your app holds for third-party services — Linear, GitHub, Slack, and more.
</Card>
<Card title="Messaging Channels" icon="messages" href="/developers/extend/apps/logic/messaging-channels">
Bring conversations from any provider into Twenty's Message and Message Thread records — threading and de-duplication included.
</Card>
<Card title="Key-Value Store" icon="database" href="/developers/extend/apps/logic/key-value-store">
Persist state between logic function runs — caches, cursors, and cross-workspace claims.
</Card>
<Card title="Background Jobs" icon="layer-group" href="/developers/extend/apps/logic/background-jobs">
Enqueue a logic function run on the workers to get past the per-run timeout.
</Card>
<Card title="Charging Credits" icon="coins" href="/developers/extend/apps/logic/credits">
Declare what your app bills for and charge workspace credits for it.
</Card>
</CardGroup>
## Trigger types at a glance
A logic function picks one or more triggers — every entry below is a separate field on `defineLogicFunction()`:
| Trigger | When it runs | Setting |
|---------|--------------|---------|
| **HTTP route** | A request hits your `/s/<path>` endpoint | `httpRouteTriggerSettings` |
| **Cron** | A CRON expression matches | `cronTriggerSettings` |
| **Database event** | A workspace record is created, updated, or deleted | `databaseEventTriggerSettings` |
| **AI tool** | A Twenty AI feature decides to call your function | `toolTriggerSettings` |
| **Workflow action** | A workflow step invokes your function | `workflowActionTriggerSettings` |
Functions run sandboxed in isolated Node.js processes and access the workspace through a typed API client scoped to the role declared on [`defineApplication()`](/developers/extend/apps/config/application).
<Note>
**Install-time hooks** — code that runs before or after the install — share this runtime but use their own define functions and live under [Config → Install Hooks](/developers/extend/apps/config/install-hooks).
</Note>

View File

@@ -0,0 +1,240 @@
---
title: Skills & Agents
description: Define AI skills and agents for your app.
icon: "robot"
---
<Warning>
Skills and agents are currently in alpha. The feature works but is still evolving.
</Warning>
Apps can define AI capabilities that live inside the workspace — reusable skill instructions and agents with custom system prompts.
<AccordionGroup>
<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/define';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
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/define';
export default defineAgent({
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'Helps the sales team draft outreach emails and research prospects',
icon: 'IconRobot',
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
});
```
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.
- `responseFormat` (optional) controls the shape of the agent's output. Defaults to `{ type: 'text' }` for free-form text. Use `{ type: 'json', schema }` to force structured JSON output.
By default an agent returns free-form text. To get structured output, set `responseFormat` to `{ type: 'json' }` and provide a `schema`:
```ts src/agents/structured-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
name: 'lead-scorer',
label: 'Lead Scorer',
prompt: 'Score the lead and explain your reasoning.',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
score: { type: 'number', description: 'Lead score from 0 to 100' },
summary: { type: 'string', description: 'Short reasoning for the score' },
},
required: ['score', 'summary'],
additionalProperties: false,
},
},
});
```
Schema notes:
- The schema is a flat object: each property's `type` must be a primitive (`string`, `number`, or `boolean`). Nested objects and arrays are not supported.
- `description` (optional) on each property guides the model on what to put there.
- `required` (optional) lists the properties the model must always return.
- `additionalProperties: false` (optional) forbids any property not declared in `properties`.
</Accordion>
<Accordion title="runAgent" description="Run an agent from a logic function">
`runAgent()` lets a logic function run one of your app's agents (with its
skills and tools). Identify the agent by the `universalIdentifier` you passed
to `defineAgent()`. Pass either a `prompt` string or a `messages` conversation
history — not both:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
});
```
For multi-turn bots (Slack, Discord, Teams, …), pass thread history as
`messages` instead of a single `prompt`:
```ts src/logic-functions/reply-in-thread.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
messages: [
{ role: 'user', content: 'Who owns Acme?' },
{ role: 'assistant', content: 'Sarah owns the Acme account.' },
{ role: 'user', content: 'What was the last touchpoint?' },
],
});
```
Key points:
- Provide **exactly one** of `prompt` (string) or `messages` (1 to 100 entries
of `{ role: 'user' | 'assistant', content: string }`). A `user` message can
also carry file attachments — see below.
- The agent runs **synchronously** and can read/update records itself via its
own tools — `runAgent()` resolves once the run completes.
- An app can only run its own agents.
- The app's [default role](/developers/extend/apps/config/roles) must grant the
`AI` permission flag — add `SystemPermissionFlag.AI` to its
`permissionFlagUniversalIdentifiers` (or set `canAccessAllTools: true`).
Without it, `runAgent()` fails with a permission error.
- Set a generous `timeoutSeconds` on the logic function — agent runs can take
several seconds.
- `success` is `true` and `result` is non-null when the run completes; on
failure `success` is `false`, `result` is `null`, and `error` holds the
reason (for example, when the workspace ran out of AI credits mid-run).
```ts src/roles/default-role.ts
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
export default defineApplicationRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
// runAgent() requires the AI permission flag on the app's default role.
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
});
```
<Warning>
**Avoid loops:** if you call `runAgent()` from a `*.updated` database-event
trigger and the agent updates the same record, scope the trigger with
`updatedFields` to a field the agent never writes (e.g. the source URL), or
guard on whether any target field is still empty before calling `runAgent()`.
</Warning>
### Sending files with a message
A `user` message can carry `attachments`, so the agent sees a screenshot or a
PDF instead of only its name. Each entry references a file your app has already
uploaded into the `agent-chat` folder:
```ts src/logic-functions/describe-screenshot.ts
const { result } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
messages: [
{
role: 'user',
content: 'What does this error screen say?',
attachments: [{ fileId: uploadedFileId, filename: 'error.png' }],
},
],
});
```
Uploading requires the `UPLOAD_FILE` permission flag on the app's default role,
alongside `AI`. `filename` is optional and only labels the file for the model.
- Only `user` messages may carry attachments. They cannot be represented on an
assistant turn, so `runAgent()` rejects them there rather than dropping them.
- At most **10** attachments per message.
- The `fileId` must name a fully uploaded file in the same workspace, under the
`agent-chat` folder. Anything else fails the call instead of quietly
answering without the file.
### Running on behalf of a workspace member
Pass `runAsWorkspaceMemberId` when the run is triggered by a person — a chat
bot answering a message, for instance — so the agent acts as that member
instead of as the app:
```ts src/logic-functions/answer-question.ts
const { result } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'How many open opportunities do we have?',
runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
});
```
The run then acts with the **member's own role**: it can do whatever that
member can do and nothing more, records it creates are attributed to them, and
their row-level permissions apply. The agent's role does not participate — it
is the app's default for runs with nobody behind them. Omit the field for
autonomous runs (scheduled jobs, database-event triggers): those keep the
agent's own role. The acting application stays attached to the run's context
for provenance, without narrowing the member's permissions.
Your app is responsible for mapping the person who triggered the run to a
workspace member. Naming one requires an app access token, and what a token may
name depends on whether it carries a user:
- A token with **no user attached** may name any member. A logic function runs
with one, and so do tokens minted through `client_credentials` or from an API
key.
- A token issued **on behalf of a user**, as a front component receives, may
only name that user's own member.
Anything else — a plain user session, an API key without an app token — may not
name a member at all.
<Warning>
`runAgent()` throws when the workspace member cannot be resolved — an
unknown or removed member, or one with no role. It never falls back to the
agent's own role, since that would grant more access than the caller asked
for.
</Warning>
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,132 @@
---
title: CLI
description: yarn twenty commands for executing functions, streaming logs, managing app installations, and switching remotes.
icon: "terminal"
---
The `yarn twenty` CLI is your interface to everything app-related. Full command list:
| Command | What it does | Documented in |
|---------|--------------|---------------|
| `dev` | Watch source files and live-sync changes | [Quick Start](/developers/extend/apps/getting-started/quick-start) |
| `plan` | Preview metadata changes without applying them | [Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan) |
| `apply` | Apply metadata changes after showing the plan | [Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery) |
| `pull` | **Experimental.** Write the installed application back to local source files | [Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery#pulling-an-installed-app-back-to-source) |
| `dev:build` | Compile the app and generate the API client (`--tarball` to pack a `.tgz`) | [Publishing](/developers/extend/apps/operations/publishing) |
| `dev:typecheck` | Run TypeScript type checking | [Testing](/developers/extend/apps/operations/testing) |
| `dev:add` | Scaffold a new entity | [Scaffolding](/developers/extend/apps/getting-started/scaffolding) |
| `dev:generate-client` | Regenerate the typed API client | this page |
| `dev:function:exec` / `dev:function:logs` | Execute functions and stream their logs | this page |
| `dev:translations-extract` | Extract translatable strings into `locales/` catalogs | [Translations](/developers/extend/apps/operations/translations) |
| `dev:catalog-sync` | Trigger a marketplace catalog sync | [Publishing](/developers/extend/apps/operations/publishing#how-marketplace-discovery-works) |
| `app:publish` / `app:install` / `app:uninstall` | Release lifecycle | [Publishing](/developers/extend/apps/operations/publishing) and this page |
| `docker:*` | Manage the local Twenty server container | [Local Server](/developers/extend/apps/getting-started/local-server) |
| `remote:*` | Manage server connections | this page |
Every command accepts `-r, --remote <name>` to target a specific remote instead of the default one.
## Executing functions (`yarn twenty dev:function:exec`)
Run a logic function manually without triggering it via HTTP, cron, or database event:
```bash filename="Terminal"
# Execute by function name
yarn twenty dev:function:exec -n create-new-post-card
# Execute by universalIdentifier
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
# Pass a JSON payload
yarn twenty dev:function:exec -n create-new-post-card -p '{"name": "Hello"}'
# Execute the install and uninstall hooks
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
yarn twenty dev:function:exec --uninstall
```
## Viewing function logs (`yarn twenty dev:function:logs`)
Stream execution logs for your app's logic functions:
```bash filename="Terminal"
# Stream all function logs
yarn twenty dev:function:logs
# Filter by function name
yarn twenty dev:function:logs -n create-new-post-card
# Filter by universalIdentifier
yarn twenty dev:function:logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
<Note>
This is different from `yarn twenty docker:logs`, which shows the Docker container logs. `yarn twenty dev:function:logs` shows your app's function execution logs from the Twenty server.
</Note>
## Generating the typed client (`yarn twenty dev:generate-client`)
Regenerate the typed API client (`twenty-client-sdk`) from the active remote's schema, without building or syncing an app. Use it to get a typed client in any project — like a backend service living in a separate repository — that talks to your Twenty instance:
```bash filename="Terminal"
# In your project (no Twenty app definition required)
yarn add twenty-sdk twenty-client-sdk
# Connect to the Twenty instance to generate the client from
yarn twenty remote:add
# Generate the typed client into node_modules/twenty-client-sdk
yarn twenty dev:generate-client
```
Then import the client in your code:
```typescript
import { CoreApiClient } from 'twenty-client-sdk/core';
```
Re-run the command whenever your data model changes to refresh the generated types.
<Note>
The client is generated inside `node_modules`, so it is not committed with your code. Run `yarn twenty dev:generate-client` after every install (for example in a `postinstall` script or in CI).
</Note>
## Uninstalling an app (`yarn twenty app:uninstall`)
Remove your app from the active workspace:
```bash filename="Terminal"
yarn twenty app:uninstall
# Skip the confirmation prompt
yarn twenty app:uninstall --yes
```
## Managing remotes
A **remote** is a Twenty server that your app connects to. During setup, the scaffolder creates one for you automatically. You can add more remotes or switch between them at any time.
```bash filename="Terminal"
# Add a new remote (opens a browser for OAuth login)
yarn twenty remote:add
# Connect to a local Twenty server (auto-detects port 2020 or 3000)
yarn twenty remote:add --local
# Add a remote non-interactively (useful for CI)
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
# List all configured remotes
yarn twenty remote:list
# Set the active remote
yarn twenty remote:use <name>
# Check that the active remote's authentication is still valid
yarn twenty remote:status
# Remove a remote
yarn twenty remote:remove <name>
```
Your credentials are stored in `~/.twenty/config.json`.

View File

@@ -0,0 +1,35 @@
---
title: Overview
description: Build, test, and ship your app — CLI commands, integration tests, CI, translations, and publishing to a server or to npm.
icon: "rocket"
---
The **operations layer** is everything you do *to* your app rather than *with* it: invoking CLI commands, running integration tests against a real Twenty server, configuring CI, translating its strings, and shipping releases — either as a tarball deployed to a single server or as an npm package listed in the marketplace.
```text
develop ─▶ test ─▶ build ─▶ deploy / publish
─────── ──── ───── ─────────────────
yarn yarn yarn yarn twenty app:publish --private (tarball → one server)
twenty test twenty
dev dev:build yarn twenty app:publish (npm → marketplace)
```
## In this section
<CardGroup cols={2}>
<Card title="CLI" icon="terminal" href="/developers/extend/apps/operations/cli">
`yarn twenty` reference — exec, logs, uninstall, remotes.
</Card>
<Card title="Syncing & recovery" icon="compass" href="/developers/extend/apps/operations/sync-and-recovery">
Which command when, reading the sync diff, and a recovery ladder.
</Card>
<Card title="Testing" icon="flask" href="/developers/extend/apps/operations/testing">
Vitest setup, integration tests, type checking, CI workflow.
</Card>
<Card title="Translations" icon="language" href="/developers/extend/apps/operations/translations">
Mark strings, extract catalogs, ship your app in several languages.
</Card>
<Card title="Publishing" icon="upload" href="/developers/extend/apps/operations/publishing">
Build, deploy a tarball, publish to npm, install.
</Card>
</CardGroup>

View File

@@ -0,0 +1,313 @@
---
title: Publishing
icon: "upload"
description: Distribute your Twenty app to the marketplace or deploy it internally.
---
## Overview
Once your app is [built and tested locally](/developers/extend/apps/getting-started/concepts), you have two paths for distributing it:
- **Deploy a tarball** — upload your app directly to a specific Twenty server for internal or private use.
- **Publish to npm** — list your app in the Twenty marketplace for any workspace to discover and install.
Both paths start from the same **build** step.
## Registration ownership
Publishing an app creates an **application registration**: the app's identity on the instance, shared by every workspace that installs it. It holds the app's marketplace entry, its OAuth client, and its server-side state — the `serverVariables` an operator configures and the `server`-scoped entries of the [key-value store](/developers/extend/apps/logic/key-value-store).
A `universalIdentifier` is unique across the whole instance and a registration belongs to at most one workspace (its owner) — or none until it is claimed — so installing an app and developing one are two different rights:
| Action | Requirement |
|--------|-------------|
| Installing an app | The app is listed on the marketplace, pre-installed by the operator, or its registration belongs to your workspace. |
| Developing an app (`yarn twenty dev`, `apply`, `plan`) | The registration belongs to your workspace. |
Syncing code and a manifest under an identifier is a claim of authorship, so being able to install an app never grants the right to publish code under its identity. A workspace that is not the owner is refused, whether the app is listed, pre-installed, or private.
Two situations lead to that refusal:
- **The identifier belongs to another workspace.** Change the `universalIdentifier` in your app config, which is the usual answer when you forked someone else's app. If you need to keep the identifier, the owning workspace has to transfer the registration to yours.
- **The identifier belongs to no workspace.** Apps imported by the marketplace catalog sync start unowned. Claim yours through npm trusted publishing before developing on it.
## Building your app
Run the build command to compile your app and generate a distribution-ready `manifest.json`:
```bash filename="Terminal"
yarn twenty dev:build
```
This compiles TypeScript sources, transpiles logic functions and front components, and writes everything to `.twenty/output/`. Add `--tarball` to also produce a `.tgz` package for manual distribution or the publish command.
## Deploying to a server (tarball)
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can deploy a tarball directly to a Twenty server.
### Prerequisites
Before deploying, you need a configured remote pointing to the target server. Remotes store the server URL and authentication credentials locally in `~/.twenty/config.json`.
Add a remote:
```bash filename="Terminal"
yarn twenty remote:add --url https://your-twenty-server.com --as production
```
### Deploying
Build and upload your app to the server in one step:
```bash filename="Terminal"
yarn twenty app:publish --private
# To deploy to a specific remote:
# yarn twenty app:publish --private --remote production
```
### Sharing a deployed app
Tarball apps are not listed in the public marketplace, so other workspaces on the same server won't discover them by browsing. To share a deployed app:
1. Go to **Settings > Applications > Registrations** and open your app
2. In the **Distribution** tab, click **Copy share link**
3. Share this link with users on other workspaces — it takes them directly to the app's install page
The share link uses the server's base URL (without any workspace subdomain) so it works for any workspace on the server.
### Version management
When updating an already deployed tarball app, the server requires the `version` in `package.json` to be **strictly higher** (per [semver](https://semver.org) ordering) than the currently deployed version. Re-deploying the same version, or pushing a lower one, is rejected before the tarball is stored — you'll see a `VERSION_ALREADY_EXISTS` error from the CLI.
To release an update:
1. Bump the `version` field in your `package.json` (e.g. `1.2.3` → `1.2.4`, `1.3.0`, or `2.0.0`)
2. Run `yarn twenty app:publish --private` (or `yarn twenty app:publish --private --remote production`)
3. Workspaces that have the app installed and enabled auto-upgrade for it (in the app's Settings tab) are upgraded automatically in the background; the others will see the upgrade available in their settings
<Note>
Pre-release tags work as expected: bumping `1.0.0-rc.1` → `1.0.0-rc.2` is allowed, and a final release like `1.0.0` is correctly recognized as higher than `1.0.0-rc.5`. The version in `package.json` must itself be a valid semver string.
</Note>
{/* TODO: add screenshot of the Upgrade button */}
### Server version compatibility
If your app uses a feature introduced in a specific Twenty server version (for example, OAuth providers added in v2.3.0), you should declare the minimum server version your app requires using the `engines.twenty` field in `package.json`:
```json filename="package.json"
{
"name": "twenty-my-app",
"version": "1.0.0",
"engines": {
"node": "^24.5.0",
"twenty": ">=2.3.0"
}
}
```
The value is a standard [semver range](https://github.com/npm/node-semver#ranges). Common patterns:
| Range | Meaning |
|-------|---------|
| `>=2.3.0` | Any server from 2.3.0 onward |
| `>=2.3.0 <3.0.0` | 2.3.0 or later, but below the next major |
| `^2.3.0` | Same as `>=2.3.0 <3.0.0` |
**What happens at deploy and install time:**
- If `engines.twenty` is set and the target server's version does not satisfy the range, the deploy (tarball upload) or install is rejected with a `SERVER_VERSION_INCOMPATIBLE` error and a message indicating both the required range and the actual server version.
- If `engines.twenty` is **not set**, the app is accepted on any server version (backward-compatible with existing apps).
- If the server has no `APP_VERSION` configured, the check is skipped.
<Note>
The server is the authoritative check — it validates `engines.twenty` on both tarball upload and workspace install. If you deploy a tarball out-of-band or install from the marketplace, the server still enforces compatibility.
</Note>
## Automated CI/CD (scaffolded workflows)
Apps generated with `create-twenty-app` ship with three GitHub Actions workflows out of the box, under `.github/workflows/`. CI runs with no setup, CD requires a single secret, and publishing to npm requires a one-time npm trusted-publisher setup.
### CI — `ci.yml`
Runs integration tests on every push to `main` and every pull request.
**What it does:**
1. Checks out your app's source.
2. Spawns an isolated Twenty test instance using the `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` composite action (the CI equivalent of `yarn twenty docker:start --test`).
3. Enables Corepack, sets up Node.js from your `.nvmrc`, and installs dependencies with `yarn install --immutable`.
4. Runs `yarn test`, passing `TWENTY_API_URL` and `TWENTY_API_KEY` from the spawned instance so your tests can talk to a real server.
**Config knobs:**
- `TWENTY_VERSION` (env, defaults to `latest`) — pin the Twenty server version used in CI by editing this in `ci.yml`.
- Concurrency is grouped by `github.ref` and cancels in-progress runs on new pushes.
No secrets are required — the test instance is ephemeral and lives only for the duration of the job.
### CD — `cd.yml`
Deploys your app to a configured Twenty server on every push to `main`, and optionally from a pull request when the `deploy` label is applied.
**What it does:**
1. Checks out the PR head (for labeled PRs) or the pushed commit.
2. Runs `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — the CI equivalent of `yarn twenty app:publish --private`.
3. Runs `twentyhq/twenty/.github/actions/install-twenty-app@main` so the newly deployed version is installed into the target workspace.
**Required configuration:**
| Setting | Where | Purpose |
|---------|-------|---------|
| `TWENTY_DEPLOY_URL` | `env` in `cd.yml` (defaults to `http://localhost:3000`) | The Twenty server to deploy to. Change this to your real server URL before first use. |
| `TWENTY_DEPLOY_API_KEY` | GitHub repo **Settings → Secrets and variables → Actions** | API key with deploy permission on the target server. |
<Note>
The default `TWENTY_DEPLOY_URL` of `http://localhost:3000` is a placeholder — it will not reach anything from a GitHub-hosted runner. Update it to your server's public URL (or use a self-hosted runner with network access) before enabling CD.
</Note>
**Triggering a preview deploy from a PR:**
Add the `deploy` label to a pull request. The `if:` guard in `cd.yml` will run the job for that PR using the PR's head commit, letting you validate a change on the target server before merging.
### Publish — `publish.yml`
Publishes your app to npm with provenance when you push a version tag (e.g. `v1.0.0`), or when you run the workflow manually from the Actions tab.
**What it does:**
1. Checks out your app, sets up Node.js, and updates npm (trusted publishing requires npm 11.5.1 or later).
2. Runs `yarn twenty app:publish`, which builds the app and publishes `.twenty/output` to npm. In CI it automatically adds `--provenance` and `--access public`, so no flags are needed in the workflow.
**One-time setup:**
On npmjs.com open your package > **Settings → Trusted Publisher** and register this repository with the `publish.yml` workflow (see the [npm trusted publishing docs](https://docs.npmjs.com/trusted-publishers)). Publishing with provenance certifies which GitHub repository built the package, which is also how you claim ownership of your app in a Twenty marketplace.
<Note>
npm only accepts provenance from **public** source repositories. If you publish from a private repo, npm rejects the OIDC provenance bundle with an `E422 ... Unsupported GitHub Actions source repository visibility: "private"` error. To publish from a private repo, opt out of provenance by setting `TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'` in the publish step's `env` (a commented-out hint is included in the scaffolded `publish.yml`):
```yaml filename=".github/workflows/publish.yml"
- name: Publish to npm
env:
TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'
run: yarn twenty app:publish
```
</Note>
### Pinning the reusable actions
The `ci.yml` and `cd.yml` workflows reference reusable actions at `@main`, so action updates in the `twentyhq/twenty` repo are picked up automatically. If you want deterministic builds, replace `@main` with a commit SHA or release tag on each `uses:` line.
## Publishing to npm
Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twenty workspace can browse, install, and upgrade marketplace apps directly from the UI.
### Requirements
- An [npm](https://www.npmjs.com) account
- The `twenty-app` keyword in your `package.json` `keywords` array (add it manually — it is not included by default in the `create-twenty-app` template)
```json filename="package.json"
{
"name": "twenty-app-postcard-sender",
"version": "1.0.0",
"keywords": ["twenty-app"]
}
```
### Marketplace metadata
The `defineApplication()` config supports optional fields that control how your app appears in the marketplace. Use `logo` and `galleryImages` to reference images from the `public/` folder:
```ts src/application-config.ts
export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
description: 'A great app',
logo: 'public/logo.png',
galleryImages: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
});
```
See the [defineApplication accordion](/developers/extend/apps/config/application#marketplace-metadata) in the Building Apps page for the full list of marketplace fields (`author`, `category`, `aboutDescription`, `websiteUrl`, `termsUrl`, etc.).
#### Recommended gallery image dimensions
The marketplace renders `galleryImages` in a fixed `8:5` container (for example, `1600×1000 px`).
<Note>
Gallery images of any aspect ratio are displayed in full and are never cropped, but anything significantly taller or narrower than `8:5` will show empty bands on the sides.
</Note>
#### Image size limit
The `logo` and each `galleryImages` file must not exceed **10 MB**. Larger files are skipped when the marketplace rehosts your published assets, so they will not be displayed.
### Publish
```bash filename="Terminal"
yarn twenty app:publish
```
To publish under a specific dist-tag (e.g., `beta` or `next`):
```bash filename="Terminal"
yarn twenty app:publish --tag beta
```
### How marketplace discovery works
The Twenty server syncs its marketplace catalog from the npm registry **every hour**.
You can trigger the sync immediately instead of waiting:
```bash filename="Terminal"
yarn twenty dev:catalog-sync
# To target a specific remote:
# yarn twenty dev:catalog-sync --remote production
```
The metadata shown in the marketplace comes from your `defineApplication()` config — see [Marketplace metadata](#marketplace-metadata) above.
<Note>
If your app does not define an `aboutDescription` in `defineApplication()`, the marketplace will automatically use your package's `README.md` from npm as the about page content. This means you can maintain a single README for both npm and the Twenty marketplace. If you want a different description in the marketplace, explicitly set `aboutDescription`.
The About tab renders markdown text only. Images do not render, whether they use markdown syntax or `<img>` tags, and raw HTML such as badges or `<div align="center">` blocks shows as plain text.
</Note>
### CI publishing
The scaffolded `publish.yml` workflow described above publishes to npm automatically on version tags, with provenance. Because `yarn twenty app:publish` adds `--provenance` and `--access public` for you when it runs in CI, the workflow needs no npm flags — only the one-time trusted-publisher setup.
For other CI systems (GitLab CI, CircleCI, etc.), run `yarn install` then `yarn twenty app:publish`. Provenance is emitted when the environment can mint an OIDC token and skipped automatically otherwise.
<Note>
**npm provenance** adds a trust badge to your npm listing, letting users verify the package was built from a specific commit in a public CI pipeline. It is also what lets you claim ownership of your app in a Twenty marketplace. See the [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements) for details.
</Note>
## Installing apps
Once an app is published (npm) or deployed (tarball), workspaces can install it through the UI.
Go to the **Settings > Applications** page in Twenty, where both marketplace and tarball-deployed apps can be browsed and installed.
{/* TODO: add screenshot of the UI when the app is registered */}
You can also install apps from the command line:
```bash filename="Terminal"
yarn twenty app:install
```
<Note>
The server enforces semver versioning on install, mirroring the rules on deploy:
- Installing the same version that is already installed in your workspace is rejected with an `APP_ALREADY_INSTALLED` error.
- Installing a lower version than the one currently installed is rejected with a `CANNOT_DOWNGRADE_APPLICATION` error.
To install a newer version, deploy or publish it first, then re-run `yarn twenty app:install`.
</Note>

View File

@@ -0,0 +1,177 @@
---
title: Syncing & recovery
description: Which command to use when, how to read the sync output, and a recovery ladder for when local metadata drifts — before reaching a full reset.
icon: "compass"
---
Local app development revolves around **syncing**: the CLI rebuilds your manifest and the server applies only the difference between it and the metadata already in your workspace. This page covers which command to reach for, how to read what a sync changed, and what to do — in order — when local state looks inconsistent.
## Which command, when
<Note>
For day-to-day local iteration you almost always want `yarn twenty dev`. Deploying and publishing are for shipping releases, **not** for the local loop.
</Note>
| You want to… | Command | Notes |
| --- | --- | --- |
| Iterate locally with live sync | `yarn twenty dev` | Watches your files and syncs on every change. |
| Sync once and exit (CI, scripts, hooks) | `yarn twenty apply` | One build + sync, then exits. Add `--force` to skip the destructive-change confirmation. |
| Preview changes **without applying them** | `yarn twenty plan` | Computes and prints the diff; writes nothing. |
| Sync without deleting anything | `yarn twenty apply --no-delete` | Creates and updates only: entities that exist in the workspace but not in your source are left alone. Also accepted by `plan` and `dev`. |
| Regenerate local source from the workspace | `yarn twenty pull` | **Experimental.** Writes the installed app back to define files. See below. |
| Remove the app from the workspace | `yarn twenty app:uninstall` | Add `--yes` to skip the prompt. |
| Ship a tarball to a server | `yarn twenty app:publish --private` | Requires a **strictly higher** `package.json` version — see [Publishing](/developers/extend/apps/operations/publishing). |
| Publish to the marketplace (npm) | `yarn twenty app:publish` | — |
| Install / upgrade a deployed version | `yarn twenty app:install` | Installs the version currently deployed. |
| Wipe the local server and start clean | `yarn twenty docker:reset` | Deletes **all** local data — last resort. |
<Note>
`yarn twenty dev --once` and `yarn twenty dev --once --dry-run` still work as deprecated aliases for `yarn twenty apply` and `yarn twenty plan`.
</Note>
### Local sync does not need a version bump
The strictly-increasing `version` rule (`VERSION_ALREADY_EXISTS` on deploy, `APP_ALREADY_INSTALLED` / `CANNOT_DOWNGRADE_APPLICATION` on install) applies to **`app:publish` / `app:install`** — the release path. `yarn twenty dev` syncs your manifest in place and never requires a version change, so you don't need to touch `package.json` to iterate. If you find yourself bumping the version to test a local change, you're using the release path when you want the dev loop.
## Reading the sync output
Every sync prints the metadata changes it applied (or would apply, with `plan`), Terraform-style — one block per entity with its attributes, then a summary line:
```text filename="Terminal"
# objectMetadata "rocket" will be created
+ icon = "IconRocket"
+ labelSingular = "Rocket"
+ ...
# fieldMetadata "launchedAt" will be updated
~ isNullable = false -> true
Plan: 2 to add, 1 to change, 1 to destroy.
✓ Synced My App (4 files)
```
This is your first diagnostic: it tells you exactly which objects, fields, and layouts changed, so you can confirm a sync did what you expected before checking the UI.
Destructive changes (`to destroy`) are listed with what they drop (e.g. `objectMetadata "auditNote" — drops the table and all its rows`) and require interactive confirmation, or `--force` in scripts.
A sync deletes every entity your app owns that your source no longer declares. When a plan contains destroys it says so and points at `--no-delete`, which makes the sync additive: creates and updates are applied, nothing missing from your source is deleted, and the settings component and uninstall hook bindings of the app stay as they are when your source does not declare them. Use it when your source is intentionally partial, for example while adopting an app that was built in the UI.
When a sync fails on a single entity, the error names the offending entity and its `universalIdentifier`, for example:
```text
Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020...4337) failed
```
Use that identifier to find the entity in your manifest (and, if needed, in the workspace) instead of guessing which one conflicts.
## Previewing changes (plan)
`yarn twenty plan` builds your manifest, asks the server for the migration plan, and prints it — **without applying anything**. It's the safe way to answer "what would this sync change?" before committing to it.
```bash filename="Terminal"
yarn twenty plan
```
```text filename="Terminal"
Building manifest...
Computing metadata plan (read-only, nothing will be applied)...
# fieldMetadata "crewCapacity" will be created
+ ...
Plan: 1 to add, 1 to change, 0 to destroy.
✓ Plan complete for My App — no changes were applied
```
A plan:
- **Writes nothing** — no metadata migration, no application record update, no default role/tab changes, and no API client generation.
- Returns the **same diff** a real sync would apply, so you can review created/updated/deleted entities up front.
- Is useful before a risky change, when reviewing an AI-generated change, or in a script that should fail if an unexpected change is about to land.
<Note>
A plan only previews **metadata** changes. It also works for an app that was never synced: the server evaluates the manifest against an empty application, so the plan lists everything your source would create.
</Note>
## Pulling an installed app back to source
<Warning>
`yarn twenty pull` is **experimental**. It covers only part of an application today, its output shape may change between releases, and it overwrites the files that define the entities it pulls. It restores published translations into `locales/`, keeping the entries whose source strings are not in your tree yet in `locales/compiled/` so that a push does not lose them. Commit your work before running it, and review the diff.
</Warning>
`yarn twenty pull` is the inverse of `apply`: it reads an application out of the workspace and writes it back as define files.
```bash
yarn twenty pull -u <universalIdentifier>
```
Pull needs an existing project — scaffold one with `npx create-twenty-app@latest` first, then pull into it. Without `-u`, pull targets the application your local `defineApplication()` file declares. If `-u` names a different application than the one your project declares, pull refuses rather than leaving two applications in one tree: pull that one into its own directory. If the project has an application file whose identifier cannot be read, usually because dependencies are not installed, pull refuses too and asks you to run `yarn install` first. Add `-v` to expand the coverage sections of the report, which then name up to 20 identifiers per metadata type instead of only the totals.
**What it writes:**
- the application config;
- objects, with their fields inline;
- fields your app added to objects it does not own;
- authored indexes;
- permission flags your app declares, as `definePermissionFlag()` files;
- roles, with their object permissions, field permissions, row-level permission predicates and groups, and permission flags inline. Your app's default role is written with `defineApplicationRole()`. An object or field permission whose identifier is the one the build derives is written without it. When the default role is not one of your app's own roles, as for the workspace Custom application, the application config keeps `defaultRoleUniversalIdentifier` instead;
- views, with their fields, field groups, filters, filter groups, sorts and groups inline;
- view fields your app added to views it does not own, as standalone `defineViewField()` files;
- page layouts, with their tabs and widgets inline;
- tabs your app added to page layouts it does not own, as standalone `definePageLayoutTab()` files;
- navigation menu items, as `defineNavigationMenuItem()` files, folders included;
- widgets your app added to tabs it does not own, as standalone `definePageLayoutWidget()` files;
- published translations, as `locales/<locale>.json` for the strings pull can read back from the pulled metadata and source, and `locales/compiled/<locale>.json` for the rest.
Each entity goes to the file that already defines it, so a second pull rewrites only what changed on the server. A file is also rewritten when it uses a different define function than pull would write, for example a role that became or stopped being the default role. New entities are placed beside existing files of their kind, and a generated name that would land on an unrelated file is qualified rather than overwriting it.
**What it does not write:**
- **Not exported yet** — command menu items, logic functions and front components, skills and agents, connection providers, application variables, public assets, timeline activity types, and the stored source of your functions.
- **Derived by the engine** — system fields, default relations, default views and backing indexes, and the default page layouts of each object. The server rebuilds them on apply. An object may still point its label identifier at one of them, as junction objects created in the UI do; the pointer is written and the build resolves it without adding a field.
- **Placed on a default or foreign view** — filters, filter groups, sorts, groups and field groups added to an engine default view, or to a view owned by another application, have no source form yet and are reported. View fields added there are written as standalone files, since the view's identifier is known.
- **Placed on a default or foreign page layout** — tabs and widgets added to an engine default page layout, or to one owned by another application, are written as standalone `definePageLayoutTab()` and `definePageLayoutWidget()` files, since the identifier they attach to is known.
- **Widgets the export cannot carry yet** — on any layout, including your own, a widget of the deprecated `VIEW` type, a widget showing one of your app's own front components or command menu items, or a widget without a position for its tab's layout mode is reported, and the layout or tab is written without it.
- **Workspace runtime state** — member and API-key role assignments, webhooks, per-user navigation and navigation items pinned to a record, all excluded by design.
- **Owned by another application** — reported, never pulled.
The report at the end of a pull lists all of it, grouped by reason. Everything it names as not written is still in your workspace, so push a pulled tree back with `yarn twenty apply --no-delete` until the missing kinds are covered. A pruning `yarn twenty apply` would delete them, and `yarn twenty plan` lists exactly which.
**The base file.** Pull records what it pulled in `.twenty/pull-base.json`, which is gitignored and per checkout. That file is what a later pull compares against to leave untouched the files whose entity did not change, and to delete the file of an entity that is gone from the workspace. A checkout without it simply writes everything and deletes nothing.
## Recovery ladder
When local metadata looks wrong, escalate in this order and stop as soon as you're unblocked. Each step is more disruptive than the last.
1. **Re-sync.** Run `yarn twenty apply` again. Syncs are idempotent — re-running a clean manifest is safe and often resolves a transient hiccup.
2. **Preview the plan.** Run `yarn twenty plan` to see exactly what the next sync intends to change, without applying it.
3. **Read the named error.** If a sync fails, note the metadata type and `universalIdentifier` in the message (see above) and locate that entity in your manifest. A conflict usually points to a duplicated or re-used identifier.
4. **Uninstall and reinstall.** `yarn twenty app:uninstall`, then sync again (`yarn twenty dev`). This rebuilds the app's metadata from a clean slate while keeping the rest of your workspace intact.
5. **Full reset (last resort).** `yarn twenty docker:reset`, then re-seed and re-sync.
<Warning>
`yarn twenty docker:reset` deletes **all** data in your local instance — every workspace, record, and app. Only use it once the earlier steps have failed.
</Warning>
<Note>
Hit a metadata error? Please [open an issue](https://github.com/twentyhq/twenty/issues/new/choose) and include the failing migration message (with its metadata type and `universalIdentifier`), the `Metadata changes` output from the sync, and the commands you ran.
</Note>
## Avoid concurrent syncs on one workspace
Syncing applies metadata migrations. Running several sync, deploy, or install operations against the **same workspace at the same time** — for example, multiple terminals or AI agents iterating in parallel — can interleave those migrations and leave metadata in a partially-applied state.
The server serializes syncs per workspace to prevent this, but you should still funnel sensitive metadata operations through a **single** process rather than firing them concurrently. If you orchestrate development with multiple agents, route their sync/deploy/install calls through one queue so only one runs at a time.
## Telling failures apart
When something goes wrong, the metadata diff and named errors let you place the failure:
- **Manifest build error** — the CLI fails before syncing (`MANIFEST_BUILD_FAILED`, `TYPECHECK_FAILED`); fix your app source.
- **Registration ownership error** — the sync is refused because the app's `universalIdentifier` belongs to another workspace, or to no workspace at all; see [Registration ownership](/developers/extend/apps/operations/publishing#registration-ownership).
- **Sync / migration error** — the build succeeds but applying the diff fails, naming the entity and `universalIdentifier`; fix the conflicting metadata.
- **Dependencies size error** — the sync or install fails because the app's production `dependencies` are too large to install as a runtime layer (`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED`); move packages your logic functions do not import at runtime (UI libraries, dev tooling) to `devDependencies`.
- **App code runtime error** — the sync succeeds but your logic functions or components misbehave at runtime; check [function logs](/developers/extend/apps/operations/cli).
- **Local instance state** — none of the above and the workspace still looks wrong; work down the recovery ladder.

View File

@@ -0,0 +1,264 @@
---
title: Testing
description: Vitest setup, integration tests against a real Twenty server, type checking, and CI with GitHub Actions.
icon: "flask"
---
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.
## 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 their 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/define';
import axios from 'axios';
const handler = async (): Promise<any> => {
const { data } = await axios.get('https://api.example.com/data');
return { data };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-data',
description: 'Fetches data from an external API',
timeoutSeconds: 10,
handler,
});
```
The same works for front components:
```tsx src/front-components/chart.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { format } from 'date-fns';
const DateWidget = () => {
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'date-widget',
component: DateWidget,
});
```
### How bundling works
The build step uses esbuild to produce a single self-contained file per logic function and per front component. Imported packages are inlined into the bundle, except for the ones listed below.
**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 npm packages that work in a browser environment. Note that the sandbox implements a *partial* DOM, so a package can build cleanly and still fail at runtime; see [Current limitations](/developers/extend/apps/layout/front-components#current-limitations).
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.
Front components can also share dependencies through a [shared dependencies bundle](/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components). Packages listed in the `frontComponentSharedDependencies` field of the app's package.json are built once into a separate file and loaded from it at runtime instead of being inlined into each component, so a component that imports only shared packages carries almost no dependency code of its own.
## 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';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '<the pre-seeded local dev key>';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});
```
Create a global setup file that verifies the server is reachable, writes a test config for the SDK (`~/.twenty/config.test.json`), and syncs the app before tests run:
```ts src/__tests__/global-setup.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
export async function setup() {
const apiUrl = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
// Verify the server is running
const response = await fetch(`${apiUrl}/healthz`);
if (!response.ok) {
throw new Error(`Twenty server is not reachable at ${apiUrl}.`);
}
// Write the SDK's test config (the CLI reads config.test.json when NODE_ENV=test)
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(CONFIG_DIR, 'config.test.json'),
JSON.stringify({
remotes: { local: { apiUrl, apiKey } },
defaultRemote: 'local',
}, null, 2),
);
// Start from a clean slate, then sync the app
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({ appPath: APP_PATH });
if (!result.success) {
throw new Error(`Dev sync failed: ${result.error?.message}`);
}
}
export async function teardown() {
await appUninstall({ appPath: APP_PATH });
}
```
## 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 |
| `appDevOnce` | Build and sync the app once (same as `yarn twenty apply`) |
| `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 dev:typecheck
```
This runs `tsc --noEmit` against your app's `tsconfig.json` and reports any type errors. Scaffolded apps also ship a `yarn typecheck` script that covers test files too (`tsconfig.spec.json`).
## CI with GitHub Actions
The scaffolder generates a ready-to-use workflow at `.github/workflows/ci.yml`. On every push to `main` and every pull request, it spawns an ephemeral Twenty server in the runner (via the `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test` action), then runs `yarn lint`, `yarn typecheck`, `yarn test:unit`, and `yarn test` with `TWENTY_API_URL` / `TWENTY_API_KEY` pointing at that server. No secrets are required, and you can pin the server version via the `TWENTY_VERSION` env at the top of the workflow.
See [Publishing → Automated CI/CD](/developers/extend/apps/operations/publishing#automated-cicd-scaffolded-workflows) for a full walkthrough of the three scaffolded workflows (`ci.yml`, the `cd.yml` deploy pipeline, and `publish.yml` for npm publishing).

View File

@@ -0,0 +1,163 @@
---
title: Translations
description: Ship your app in multiple languages — translate manifest labels and UI strings through one locales catalog.
icon: "language"
---
Twenty apps are authored in **English**: the strings in your source and manifest
are the English source text, `en` is the source locale you translate *from*, and
any locale left untranslated falls back to it.
Your app has two kinds of translatable text, and both flow through the same
`locales/` catalog:
- **Manifest labels** — object and field names, view titles, menu items, and
other strings declared in your app's metadata.
- **Front-component strings** — the UI text your React front components render.
You mark the translatable strings, extract them into per-locale catalogs,
translate those catalogs, and the build serves the right language for the
current user — no extra wiring.
## Marking front-component strings
Import the translation helpers from `twenty-sdk/front-component`:
```tsx
import { Trans, t, msg, useTranslate } from 'twenty-sdk/front-component';
const STATUSES = [
{ id: 'draft', label: msg('Draft') },
{ id: 'sent', label: msg('Sent') },
];
const Card = ({ count, name }: { count: number; name: string }) => {
const { t } = useTranslate();
return (
<section>
{/* Static text — reactive to the user's locale */}
<Trans>Loading postcard…</Trans>
{/* Disambiguate identical sources with a context */}
<Trans context="card-title">Untitled</Trans>
{/* Interpolation: pass values explicitly */}
<p>{t('Hi {name}', { name })}</p>
<p>{t('Saved {count} cards', { count })}</p>
{/* Resolve a lazily-declared descriptor */}
<ul>{STATUSES.map((s) => <li key={s.id}>{t(s.label)}</li>)}</ul>
</section>
);
};
```
### When to use which
- **`Trans`** — static text in JSX. Use the `message` and `values` props for
interpolation, as in `Trans message="Hi {name}" values={{ name }}`;
interpolating directly in the children is not statically extractable.
- **`useTranslate().t`** — dynamic strings inside a component. Re-renders when the
user switches language. Prefer this inside render.
- **`t(...)`** (imported directly) — eager translation usable **anywhere**,
including event handlers, helpers, and module scope — not only inside render.
- **`msg(...)`** — a lazy descriptor for strings declared as data (constants,
config). Resolve it later with `t(descriptor)`.
### Context
Pass `context` to disambiguate identical source strings that translate
differently:
```tsx
t({ message: 'Open', context: 'door' });
t({ message: 'Open', context: 'window' });
<Trans context="card-title">Untitled</Trans>
```
Contextual strings appear in locale catalogs as a group keyed by their
context: `"<context>": { "<message>": "<translation>" }`. Manifest labels are
contextual by construction — each one carries the metadata role it plays
(`objectMetadata.labelSingular`, `fieldMetadata.label`, `view.name`, …), so
the same word can translate differently as an object name and as a view name.
## Extracting and translating
Run the extract command from your app directory:
```bash
twenty dev:translations-extract # collect strings into locales/en.json
twenty dev:translations-extract --locale fr-FR # also scaffold a target locale
```
Extraction collects both your manifest labels and the `t()`/`msg()`/`Trans`
strings from your front-component source into `locales/<locale>.json`, keyed by
source string. Fill in the translations:
```json
// locales/fr-FR.json
{
"Loading postcard…": "Chargement de la carte…",
"Hi {name}": "Bonjour {name}",
"Saved {count} cards": "{count} cartes enregistrées",
"door": {
"Open": "Ouvrir"
},
"objectMetadata.labelSingular": {
"Postcard": "Carte postale"
}
}
```
Context-free strings are flat entries; contextual strings — including every
manifest label — are nested under their context key.
Placeholders like `{name}` are substituted at runtime — keep them in the
translation. Any string left empty falls back to the source text.
## How it runs
`twenty dev:build` compiles the catalogs and serves the right language for the
current user: manifest labels are resolved server-side, and front-component
catalogs are baked into each component bundle. At runtime a component reads the
locale from its execution context (the host's current language) and resolves
each string against its catalog, falling back to the source when a translation
is missing. Switching language in the host re-renders `Trans` and
`useTranslate().t` strings live.
Manifest labels are compiled on every sync — `twenty dev:build`, `twenty apply`
and the continuous `twenty dev` watch alike — so editing a locale file and
syncing is enough to see the new text. Front component catalogs are baked into
each component bundle at build time, so changing one of those still means
re-running `twenty dev:build` (and redeploying).
## Pulling translations
`twenty pull` writes an application's published translations back into
`locales/`. A published catalog is keyed by message id, so pull recovers the
readable form only for strings whose source it can see: the labels of the
metadata it pulled and the `t()`/`msg()`/`Trans` strings of front components
present in the project. Everything else is kept as is in
`locales/compiled/<locale>.json`, keyed by message id. The build merges those
files into the catalog (an entry in `locales/<locale>.json` wins over a
compiled one with the same id), `dev:translations-extract` leaves them alone,
and every pull rewrites them, so they shrink as more of the app's source lands
in the tree. Do not edit them by hand; translate in `locales/<locale>.json`.
A project without a `locales/` folder says nothing about translations, so a
sync from it leaves whatever the app already published untouched. To remove
published translations on purpose, keep the folder and delete the locale files
inside it: a `locales/` folder that compiles to no locale is an explicit
declaration, and the sync prunes accordingly.
`Trans` text children may span multiple lines — whitespace is collapsed the
same way JSX collapses it, so both of these extract to the key `Welcome back`:
```tsx
<Trans>Welcome back</Trans>
<Trans>
Welcome
back
</Trans>
```

View File

@@ -0,0 +1,81 @@
---
title: 5. An AI agent
icon: "robot"
description: Let an agent generate documents from a chat, using your tool.
---
Because `generate-document` is exposed as a **tool**, an AI agent can call it.
Let's add an agent and a skill so users can just say *"generate a proposal for
Jeffery Griffin"*.
## The skill
A [skill](/developers/extend/apps/logic/skills-and-agents) is reusable
instructions — knowledge you attach to agents. Ours teaches the model how to use
the tool.
```ts filename="src/skills/document-drafting.skill.ts"
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
name: 'document-drafting',
label: 'Document drafting',
icon: 'IconFileText',
content: [
'To generate a document, call the `generate-document` tool with:',
'- `templateId`: the id of the document template to use.',
'- `recordId`: the id of the Person or Company the document is for.',
'',
'If the user names a template or person instead of an id, find the record first,',
'then pass its id. Make sure the template target matches the record type.',
].join('\n'),
});
```
## The agent
An [agent](/developers/extend/apps/logic/skills-and-agents) pairs a prompt with a
model. Set `responseFormat` explicitly to avoid a build warning.
```ts filename="src/agents/document-assistant.agent.ts"
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
name: 'document-assistant',
label: 'Document Assistant',
description: 'Generates documents from your templates and CRM records.',
icon: 'IconFileText',
responseFormat: { type: 'text' },
prompt: [
'You are the Document Assistant for a CRM.',
'You help users generate personalized documents from reusable templates',
'and the data already in their CRM. Use the generate-document tool, and',
'always confirm what you created.',
].join(' '),
});
```
<Note>
The agent can only call the tool if its role allows it. We already set
`canAccessAllTools: true` and `canBeAssignedToAgents: true` on the app's role in
[Chapter 2](/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
</Note>
## Try it
Open a chat with **Document Assistant** and ask it to draft a document for a
person in your CRM. It finds the record, calls `generate-document`, and reports
back the document it created — which now appears in your **Documents** view,
exactly like the command-menu and workflow paths.
That's the payoff of exposing logic as a tool: **one function, many front doors** —
command menu, HTTP, workflow step, and now natural language.
**After this step:** the app is feature-complete and genuinely useful. Time to
ship it.
<Card title="Next: publishing →" icon="rocket" href="/developers/extend/apps/tutorials/document-generator/publishing">
Add marketplace metadata and publish.
</Card>

View File

@@ -0,0 +1,314 @@
---
title: 4. Building the UI
icon: "table-columns"
description: Views, sidebar navigation, a command, and front components.
---
Right now the objects are only reachable through Settings. Let's give the app a
real presence in the UI: list views, sidebar entries, a one-click
**Generate document** command, a record-page front component to **preview** a
document, and a native rich-text **editor** tab for templates.
## Views and navigation
A [view](/developers/extend/apps/layout/views) is a saved list of a given object.
A [navigation menu item](/developers/extend/apps/layout/navigation-menu-items)
puts that view in the sidebar. The server already provisions each object's
default "All ..." view automatically, so the views an app defines are
additional, curated ones.
```ts filename="src/views/documents.view.ts"
import { defineView } from 'twenty-sdk/define';
export default defineView({
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All documents',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconFile',
position: 0,
fields: [
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0, isVisible: true, size: 280 },
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1, isVisible: true, size: 120 },
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2, isVisible: true, size: 200 },
],
});
```
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Documents',
icon: 'IconFile',
color: 'green',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
});
```
Add the same pair for templates. Both now show in the sidebar:
<Frame caption="Documents and Templates in the sidebar, with the generated document listed.">
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Documents view with a generated document" />
</Frame>
## A front component
A [front component](/developers/extend/apps/layout/front-components) is a React
component sandboxed inside Twenty. Ours reads the selected record, loads the
person templates via `CoreApiClient`, and POSTs to the route from the last
chapter.
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
const GenerateDocumentForm = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [templateId, setTemplateId] = useState('');
useEffect(() => {
new CoreApiClient()
.query({ documentTemplates: {
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
edges: { node: { id: true, name: true } } } })
.then(({ documentTemplates }) => {
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
setTemplates(list);
if (list[0]) setTemplateId(list[0].id);
});
}, []);
const generate = async () => {
const res = await new RestApiClient().post<{ success: boolean }>(
'/s/documents/generate',
{ templateId, recordId },
);
await enqueueSnackbar({
message: res.success ? 'Document generated.' : 'Generation failed.',
variant: res.success ? 'success' : 'error',
});
};
// ...render a <select> of templates and a Generate button
};
export default defineFrontComponent({
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-document-form',
component: GenerateDocumentForm,
});
```
<Warning>
Style with inline CSS variables (`var(--t-color-blue)`), not values imported from
`twenty-ui`. The SDK mocks that package during build, so module-level imports of
theme constants would be `undefined`. See the
[full component](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
</Warning>
## A command to open it
A [command menu item](/developers/extend/apps/layout/command-menu-items) with
`availabilityType: 'RECORD_SELECTION'` shows up when a Person is selected, and
opens the component in the side panel.
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Generate document',
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
```
## Try the whole flow
Open **People**, tick a person, and press <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>.
"Generate document" appears, tagged with your app:
<Frame caption="The command shows up when a Person is selected.">
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Command menu with Generate document" />
</Frame>
Run it — your component opens in the side panel. Pick a template, click
**Generate**, and a new record lands in **Documents**.
<Frame caption="The front component, loading templates and generating on click.">
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generate document side panel" />
</Frame>
Every generated document records your app as its author:
<Frame caption="Created by Document Generator, status Generated.">
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="A generated document record" />
</Frame>
## Preview a document on its record page
A front component isn't only for command menus — you can mount one as a **tab on a
record page**. Let's add a *Preview* tab to the document record that renders the
Markdown body as a polished, printable page.
The component reads the current record id from its execution context, loads the
document, and renders it. Front components run in a **sandbox** that only allows a
whitelist of HTML tags — raw HTML injection (`dangerouslySetInnerHTML`) and
`<style>` are blocked — so we render the Markdown as React elements with inline
styles via a small [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
helper.
```tsx filename="src/front-components/document-viewer.front-component.tsx"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { Markdown } from 'src/utils/markdown-to-react';
const DocumentViewer = () => {
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
// ...load { content, file } for recordId, then derive the links:
const pdfUrl = document.file?.[0]?.url;
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
query: { id: recordId },
});
// Render the template body, plus quick links to the web page and the PDF.
// Links open in a new tab so they don't navigate the embedded component.
return (
<div style={styles.scroll}>
<div style={styles.actions}>
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
Open web page
</a>
{pdfUrl ? (
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
Download PDF
</a>
) : null}
</div>
<div style={styles.paper}>
<div style={styles.body}>
<Markdown content={document.content} />
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'document-viewer',
component: DocumentViewer,
});
```
Mount it with a [page layout](/developers/extend/apps/layout/page-layouts). A
`RECORD_PAGE` layout adds tabs to an object's record view; a tab with a single
`FRONT_COMPONENT` widget hosts the component full-bleed:
```ts filename="src/page-layouts/document-record.page-layout.ts"
import {
definePageLayout,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Document record page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
icon: 'IconEye',
position: 50,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Document preview',
type: 'FRONT_COMPONENT',
heightBehavior: 'TAB_VIEWPORT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
}],
}],
});
```
Open any document — a **Preview** tab renders it beautifully, with links to the
shareable web page and the PDF:
<Frame caption="The Preview tab renders the document with inline styles, plus quick links.">
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Document viewer front component in a record-page tab" />
</Frame>
## Edit a template with the rich-text editor
Templates don't need a custom component at all. Because the `body` is a
`RICH_TEXT` field, Twenty already provides a full rich-text editor for it — the
same one the standard Note and Task objects use. We just surface it on the
template record page.
Add a tab with a `FIELD` widget in `EDITOR` display mode, pointing at the `body`
field via `fieldMetadataId`:
```ts filename="src/page-layouts/template-record.page-layout.ts"
{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Template',
position: 1,
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Template',
type: 'FIELD',
position: { layoutMode: PageLayoutTabLayoutMode.GRID, row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
configuration: {
configurationType: 'FIELD',
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'EDITOR',
},
}],
}
```
A `RICH_TEXT` field stores both the editor's block JSON and a Markdown
projection. The generation pipeline reads that Markdown projection, so
placeholders, the PDF, and the shareable web page all keep working unchanged —
see the full
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
`FIELD` widgets support other display modes and options too, including
listing records of a nested relation two hops away — see the
[field widget reference](/developers/extend/apps/layout/page-layouts#field-widgets).
Now editors write templates in a proper rich-text editor:
<Frame caption="The Template tab: Twenty's native rich-text editor bound to the body field.">
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Template record with the native rich-text editor tab" />
</Frame>
**After this step:** documents preview beautifully and templates are editable
in-app. Next, let an AI agent generate them from a chat.
<Card title="Next: an AI agent →" icon="robot" href="/developers/extend/apps/tutorials/document-generator/ai-agent">
Add an agent and a skill that call your tool.
</Card>

View File

@@ -0,0 +1,135 @@
---
title: 1. Data model
icon: "database"
description: Model documents and templates with objects, fields, and a relation.
---
Our app needs two custom objects: **document templates** (what to write) and
**documents** (the generated result). Let's define them.
Scaffold each entity file with the CLI — it generates a valid UUID and the right
folder for you:
```bash filename="Terminal"
yarn twenty dev:add object
```
Below we show the finished files.
<Note>
Every `*_UNIVERSAL_IDENTIFIER` constant lives in
`src/constants/universal-identifiers.ts` and is imported where used. The snippets
below omit those imports for brevity — keep them in your own files.
</Note>
## The template object
A template has a `name`, a `body` with `{{placeholders}}`, and a `target` that
says whether it's written for a Person or a Company. The `body` is a
`RICH_TEXT` field, so Twenty gives it a full rich-text editor.
```ts filename="src/objects/document-template.object.ts"
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'documentTemplate',
namePlural: 'documentTemplates',
labelSingular: 'Document template',
labelPlural: 'Document templates',
icon: 'IconFileText',
labelIdentifierFieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
defaultValue: `'PERSON'`,
options: [
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
] },
],
});
```
<Warning>
`SELECT` option **values** must be `UPPER_CASE` (`PERSON`, not `person`), and the
`defaultValue` is wrapped in extra quotes: `` `'PERSON'` ``. The `label` is what
users see.
</Warning>
## The document object
The generated document stores the rendered `content` and a `status`. Define it
the same way, with a `status` select of `DRAFT` / `GENERATED`. Full file:
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
## Linking them with a relation
Each document should point back to the template it came from. Relations are
**bidirectional** — you define both sides, each in its own field file.
```ts filename="src/fields/document-template-relation.field.ts"
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
// The "many" side: each document belongs to one template.
export default defineField({
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'template',
label: 'Template',
relationTargetObjectMetadataUniversalIdentifier:
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'templateId',
},
});
```
The other side (`template-documents-relation.field.ts`) is a
`RelationType.ONE_TO_MANY` field named `documents` that points the opposite way.
See [Relations](/developers/extend/apps/data/relations) for the full pattern.
## See it in Twenty
With `yarn twenty dev` running, open **Settings → Data model**. Both objects
appear, tagged with your app.
<Frame caption="Both custom objects, owned by the Document Generator app.">
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Data model settings showing Documents and Document templates" />
</Frame>
Create one template to test with — name it *Sales proposal*, set **Target** to
*Person*, and paste a body with a few placeholders:
```text
Dear {{name.firstName}} {{name.lastName}},
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
Best,
The Team
```
<Frame caption="A template record. The body keeps its placeholders until a document is generated.">
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="A Sales proposal template record with placeholder body" />
</Frame>
**After this step:** you have `documentTemplate` and `document` objects, linked by
a relation, and one template to generate from. Next, the logic that fills it in.
<Card title="Next: generating documents →" icon="bolt" href="/developers/extend/apps/tutorials/document-generator/generating-documents">
Write the logic function that fills the template.
</Card>

View File

@@ -0,0 +1,239 @@
---
title: 2. Generating documents
icon: "bolt"
description: One logic function, exposed as an AI tool and a workflow action.
---
Now the core: a [logic function](/developers/extend/apps/logic/logic-functions)
that loads a template and a record, fills the placeholders, and saves a new
document.
We'll write the business logic once as a **handler**, then expose it through
several triggers. This chapter wires up two of them — an **AI tool** and a
**workflow action**.
## The rendering helper
Keep pure logic in its own file so it's easy to unit-test. This flattens a record
into `{{dot.path}}` tokens and substitutes them.
```ts filename="src/logic-functions/utils/render-template.ts"
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
export const renderTemplate = (body: string, values: Record<string, string>) => {
const missingTokens = new Set<string>();
const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
const value = values[token];
if (value === undefined || value === '') { missingTokens.add(token); return ''; }
return value;
});
return { content, missingTokens: [...missingTokens] };
};
```
<Tip>
Because this file has no side effects, you can cover it with fast unit tests
(`yarn test:unit`). See [Testing](/developers/extend/apps/operations/testing).
</Tip>
## The handler
The handler uses the generated [`CoreApiClient`](/developers/extend/apps/logic/logic-functions)
to read and write CRM data. It loads the template, loads the target record, fills
the body, and creates a `document`.
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
import { renderTemplate } from 'src/logic-functions/utils/render-template';
export const generateDocumentHandler = async (
input: { templateId: string; recordId: string },
) => {
const client = new CoreApiClient();
// Use a filtered list query, not the singular lookup: the singular query
// throws when nothing matches, which would become a 500 instead of a 404.
const { documentTemplates } = await client.query({
documentTemplates: {
__args: { filter: { id: { eq: input.templateId } }, first: 1 },
edges: { node: { id: true, name: true, body: true, target: true } },
},
});
const documentTemplate = documentTemplates?.edges?.[0]?.node;
if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };
const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
if (!record.found) return { success: false, status: 404, message: 'Record not found.' };
const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);
const { createDocument } = await client.mutation({
createDocument: {
__args: { data: {
name: `${documentTemplate.name} — ${record.displayName}`,
content, status: 'GENERATED', templateId: documentTemplate.id,
} },
id: true, name: true,
},
});
return { success: true, documentId: createDocument.id, content, missingTokens };
};
```
`loadRecordValues` runs a different query for a Person vs. a Company and flattens
the result — see
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts).
## Expose it as a tool and a workflow action
A single `defineLogicFunction` can carry several triggers. Here, `toolTriggerSettings`
makes it callable by AI agents, and `workflowActionTriggerSettings` turns it into a
step in the visual workflow builder. Both describe their input with a JSON schema.
```ts filename="src/logic-functions/generate-document.ts"
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'generate-document',
description: 'Generate a document from a template and a CRM record.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: generateDocumentInputSchema,
},
workflowActionTriggerSettings: {
label: 'Generate Document',
icon: 'IconFileText',
inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
outputSchema: [{ type: 'object', properties: {
success: { type: 'boolean' }, documentId: { type: 'string' },
} }],
},
handler: generateDocumentHandler,
});
```
The input schema is a plain JSON schema describing `templateId` and `recordId` —
see [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts).
## Grant it access
Logic functions run as the app's role. It needs to read templates and records
and create documents, so allow that in `src/roles/default-role.ts`:
```ts
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Document Generator default role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canAccessAllTools: true,
canBeAssignedToAgents: true,
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});
```
`UPLOAD_FILE` lets the function upload the generated PDF in the next section.
See [Roles](/developers/extend/apps/config/roles) for finer-grained permissions.
## Attach a real PDF file
A rendered text field is useful, but users want a real document. Let's generate a
**PDF** and store it on the record as a downloadable file.
First, give the `document` object a `FILES` field to hold the PDF. Apps upload
into their **own** files fields, so this field is what routes the upload:
```ts filename="src/objects/document.object.ts"
{
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'file',
label: 'File',
icon: 'IconFileTypePdf',
universalSettings: { maxNumberOfValues: 1 },
}
```
Now render that PDF. An app is a real Node project, so you can add any npm
package you need and import it like anywhere else. We use **[pdf-lib](https://pdf-lib.js.org/)**
to draw the PDF and **[marked](https://marked.js.org/)** to parse the Markdown
body — the CLI installs them into the function's runtime for you:
```bash filename="Terminal"
yarn add pdf-lib marked
```
The full helper is
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts).
It parses the Markdown into tokens with `marked.lexer`, then lays them out with
pdf-lib: real headings, **bold**/*italic* runs, bullet and numbered lists,
blockquotes and rules — a polished, multi-page A4 rendering of the template
itself, rather than a wall of text.
<Frame caption="The generated PDF: real typography and Markdown formatting, rendering the template body.">
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="A polished, marketable generated PDF" />
</Frame>
<Note>
pdf-lib's built-in fonts use WinAnsi encoding, so Western-European accents render
out of the box; the helper maps smart quotes and dashes and drops characters it
can't encode. Rendering non-Latin scripts (Chinese, Arabic, Cyrillic) would mean
embedding a Unicode font.
</Note>
Then upload it and store the reference on the record. `uploadFile` routes bytes
to your app-owned files field; the returned `id` is what you save:
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';
const documentName = `${documentTemplate.name} — ${record.displayName}`;
const bytes = await generateDocumentPdf(documentName, content);
const fileName = 'proposal.pdf';
const uploaded = await new MetadataApiClient().uploadFile(
Buffer.from(bytes),
fileName,
'application/pdf',
DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
await client.mutation({
updateDocument: {
__args: {
id: documentId,
data: { file: [{ fileId: uploaded.id, label: fileName }] },
},
id: true,
},
});
```
The generated document now carries a downloadable PDF:
<Frame caption="The generated PDF, stored on the document's File field.">
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="A document record with a generated PDF file" />
</Frame>
<Note>
`uploadFile` only targets **app-owned** files fields (so uploads always require an
app that owns the field, plus the `UPLOAD_FILE` role flag). That's why the PDF
lands on the record's own `file` field — the same pattern the
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
uses for recordings.
</Note>
**After this step:** each generated document has a real, downloadable PDF. But
nothing can *call* the generator from the UI yet — for that we need an HTTP route.
<Card title="Next: HTTP routes →" icon="globe" href="/developers/extend/apps/tutorials/document-generator/http-routes">
Serve the function over HTTP and render documents as web pages.
</Card>

View File

@@ -0,0 +1,150 @@
---
title: 3. HTTP routes
icon: "globe"
description: Trigger the function over HTTP and render documents as web pages.
---
The same handler can also answer HTTP requests. We'll add two routes:
- a **POST** endpoint the UI calls to generate a document, and
- a public **GET** endpoint that renders a document as a printable web page.
Both use `httpRouteTriggerSettings`. App routes are addressed with an `/s/` path; on local
and self-hosted instances, that path is served by your Twenty server
(e.g. `http://localhost:2020/s/documents/generate`). On Twenty Cloud, `RestApiClient`
resolves the path through the workspace's functions domain.
## POST route — generate on demand
This reuses `generateDocumentHandler`, so there's no logic to repeat — just a thin
adapter that reads the request body.
```ts filename="src/logic-functions/generate-document-route.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
const handler = async (event: RoutePayload): Promise<Response> => {
const body = event.body as Record<string, unknown> | null;
const result = await generateDocumentHandler({
templateId: (body?.templateId as string) ?? '',
recordId: (body?.recordId as string) ?? '',
});
// Map the handler's failure reason onto a real HTTP status (400/404/500)
// instead of always returning 200.
return new Response(JSON.stringify(result), {
status: result.success ? 200 : (result.status ?? 400),
headers: { 'Content-Type': 'application/json' },
});
};
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'generate-document-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/documents/generate',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
The shared handler returns a suggested `status` on failure, so the route can
answer with a proper `4xx`/`5xx` code. `isAuthRequired: true` means the caller
must present a valid token — the front component in the next chapter passes the
user's access token automatically.
## GET route — render as a web page
To return HTML instead of JSON, wrap the body in a `Response` with a
`Content-Type` header. This route is public (`isAuthRequired: false`) so a
generated document can be shared as a link.
```ts filename="src/logic-functions/view-document.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { documentHtmlPage } from 'src/utils/render-document';
const htmlResponse = (html: string, status = 200): Response =>
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
const handler = async (event: RoutePayload): Promise<Response> => {
const documentId = event.queryStringParameters?.id;
if (!documentId) {
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
}
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
const { documents } = await new CoreApiClient().query({
documents: {
__args: { filter: { id: { eq: documentId } }, first: 1 },
edges: { node: { id: true, name: true, content: true } },
},
});
const document = documents?.edges?.[0]?.node;
if (!document?.id) {
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
}
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
};
export default defineLogicFunction({
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'view-document',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/documents/view',
httpMethod: 'GET',
isAuthRequired: false,
},
});
```
`documentHtmlPage` renders the Markdown body to HTML (with [marked](https://marked.js.org/),
sanitized) and drops it into a clean, printable page that shows just the template
content — the same look as the PDF and the in-app preview.
[See the helper](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
## Try it
With a template and a Person in your workspace, call the route (grab a token from
**Settings → APIs & Webhooks**):
```bash filename="Terminal"
curl -X POST http://localhost:2020/s/documents/generate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
```
Open the returned document in your browser:
```
http://localhost:2020/s/documents/view?id=<documentId>
```
<Frame caption="The public GET route renders the document as a printable page.">
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="A rendered document web page" />
</Frame>
<Tip>
You can also stream a function's logs while testing with
`yarn twenty dev:function:logs`, or invoke it directly with
`yarn twenty dev:function:exec`.
</Tip>
**After this step:** the app can generate documents over HTTP and serve them as
web pages. Now let's make it usable without `curl`.
<Card title="Next: building the UI →" icon="table-columns" href="/developers/extend/apps/tutorials/document-generator/building-the-ui">
Views, navigation, a command, and a front component.
</Card>

View File

@@ -0,0 +1,70 @@
---
title: "Tutorial: Document Generator"
icon: "wand-magic-sparkles"
description: Build a real Twenty app that generates personalized documents from your CRM data.
---
In this tutorial you'll build **Document Generator** — an app that turns reusable
templates into personalized documents using the data already in your CRM.
Write a template once with `{{placeholders}}`, then generate a filled-in
document for any Person or Company in one click — from the command menu, from an
AI agent, or from a workflow.
<Frame caption="One template, generated for a specific person, opened as a printable page.">
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="A generated Sales proposal document" />
</Frame>
## What you'll learn
Each chapter adds one capability. By the end you'll have touched most of the SDK.
| Chapter | Capability | Reference |
|---|---|---|
| [1. Data model](/developers/extend/apps/tutorials/document-generator/data-model) | Objects, fields, and a relation | [Data](/developers/extend/apps/data/overview) |
| [2. Generating documents](/developers/extend/apps/tutorials/document-generator/generating-documents) | A logic function (AI tool + workflow action) that fills a Markdown template and attaches a polished PDF | [Logic functions](/developers/extend/apps/logic/logic-functions) |
| [3. HTTP routes](/developers/extend/apps/tutorials/document-generator/http-routes) | Serving JSON and a shareable HTML page from routes | [Logic functions](/developers/extend/apps/logic/logic-functions) |
| [4. Building the UI](/developers/extend/apps/tutorials/document-generator/building-the-ui) | Views, navigation, command menu, and front components that preview a document and edit a template | [Layout](/developers/extend/apps/layout/overview) |
| [5. An AI agent](/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + skill | [Skills & agents](/developers/extend/apps/logic/skills-and-agents) |
| [6. Publishing](/developers/extend/apps/tutorials/document-generator/publishing) | Ship it to the marketplace | [Publishing](/developers/extend/apps/operations/publishing) |
## Prerequisites
You should have finished the [Quick Start](/developers/extend/apps/getting-started/quick-start):
a local Twenty server running on port `2020` and the CLI authenticated to it.
If not, scaffold and start one now:
```bash filename="Terminal"
npx create-twenty-app@latest document-generator
cd document-generator
yarn twenty dev
```
<Note>
Prefer to read the finished code? The complete app lives in
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
Every snippet below is copied from it.
</Note>
## How the app fits together
<Frame>
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="A template with placeholders is generated into a polished document with a PDF, triggered from the command menu, an AI agent, a workflow, or a shareable link" />
</Frame>
You write a **template** once in a rich-text editor, with `{{placeholders}}`. Picking a
template and a CRM record fills the placeholders and stores a polished
**document** (with a PDF file). Everything else — the command menu, the AI agent,
the workflow step, the shareable link — is just a different way to trigger that
one generator.
## Keep this loop running
Leave `yarn twenty dev` running in a terminal for the whole tutorial. Every time
you add or edit a file under `src/`, it re-syncs to your server within a few
seconds, so you can watch each capability appear in the UI as you build it.
<Card title="Start building →" icon="database" href="/developers/extend/apps/tutorials/document-generator/data-model">
Chapter 1: model documents and templates.
</Card>

View File

@@ -0,0 +1,137 @@
---
title: 6. Publishing
icon: "rocket"
description: Add marketplace metadata and publish your app.
---
Your app works. The last step is to describe it for the marketplace and publish.
## Add marketplace metadata
The [application config](/developers/extend/apps/config/application) carries the
identity that shows up in the marketplace: author, category, logo, and support
links. Put a logo in `public/` and reference it with `logo`.
```ts filename="src/application-config.ts"
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Document Generator',
description:
'Create reusable document templates and generate personalized documents from your CRM records.',
logo: 'public/document-generator.svg',
author: 'Twenty',
category: 'Productivity',
websiteUrl: 'https://docs.twenty.com/developers/extend/apps',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
});
```
<Tip>
The default role is declared with `defineApplicationRole()` in its own file — you
don't pass `defaultRoleUniversalIdentifier` here anymore.
</Tip>
Also add the `twenty-app` keyword to `package.json` so the app is discoverable:
```json filename="package.json"
{ "keywords": ["twenty-app"] }
```
## Add gallery screenshots
A marketplace listing sells itself with screenshots. Drop a few PNGs in
`public/gallery/` and reference them with `galleryImages` — they render as a
gallery on the listing page, in array order.
```ts filename="src/application-config.ts"
export default defineApplication({
// ...identity from above
galleryImages: [
'public/gallery/01-generated-document.png',
'public/gallery/02-command-menu.png',
'public/gallery/03-template-editor.png',
'public/gallery/04-documents.png',
],
});
```
<Tip>
Lead with the payoff: make the first screenshot the finished result (a generated
document), then show how it's triggered and authored. Use crisp, high-resolution
captures — they're the first thing a user sees.
</Tip>
Give `README.md` the same treatment — it's the front page on npm and GitHub.
Open with the value proposition and a screenshot, list the headline features,
then keep the build details below the fold.
## Check before you ship
Run the same gates CI does:
```bash filename="Terminal"
yarn lint # oxlint
yarn typecheck # tsgo
yarn test:unit # unit tests
yarn twenty plan # preview the metadata diff
```
The plan prints exactly what would change on the server without applying it —
a good final sanity check. See
[Testing](/developers/extend/apps/operations/testing) and
[Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery).
## Publish
```bash filename="Terminal"
# Public app → npm (default)
yarn twenty app:publish
# Or deploy privately to a specific server's registry
yarn twenty app:publish --private -r <remote>
```
`app:publish` builds and publishes to npm by default; `--private` uploads a
tarball to a Twenty server's private registry instead. To surface a published app
in an instance's marketplace, trigger a catalog sync:
```bash filename="Terminal"
yarn twenty dev:catalog-sync -r <remote>
```
Full details and the release checklist:
[Publishing](/developers/extend/apps/operations/publishing).
## You built an app 🎉
In six chapters you used most of the SDK surface:
- **Objects, fields, and a relation** to model the data
- A **logic function** exposed as an **AI tool**, a **workflow action**, and **HTTP routes**
- **Views, navigation, a command, and a front component** for the UI
- An **agent + skill** for natural-language generation
- **Marketplace metadata** and the publish flow
The finished app is at
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
## Where to go next
<CardGroup cols={2}>
<Card title="Data reference" icon="database" href="/developers/extend/apps/data/overview">
Every field type, relation, and index option.
</Card>
<Card title="Logic reference" icon="bolt" href="/developers/extend/apps/logic/overview">
Cron and database-event triggers, the key-value store, OAuth connections.
</Card>
<Card title="Layout reference" icon="table-columns" href="/developers/extend/apps/layout/overview">
Page layouts, dashboard widgets, and more UI surfaces.
</Card>
<Card title="Operations" icon="rocket" href="/developers/extend/apps/operations/overview">
CLI, testing, remotes, and CI.
</Card>
</CardGroup>

View File

@@ -0,0 +1,141 @@
---
title: APIs
description: Query and modify your CRM data programmatically using REST or GraphQL.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Twenty was built to be developer-friendly, offering powerful APIs that adapt to your custom data model. We provide four distinct API types to meet different integration needs.
## Developer-First Approach
Twenty generates APIs specifically for your data model:
- **No long IDs required**: Use your object and field names directly in endpoints
- **Standard and custom objects treated equally**: Your custom objects get the same API treatment as built-in ones
- **Dedicated endpoints**: Each object and field gets its own API endpoint
- **Custom documentation**: Generated specifically for your workspace's data model
<Note>
Your personalized API documentation is available under **Settings → API & Webhooks** after creating an API key. Since Twenty generates APIs that match your custom data model, the documentation is unique to your workspace.
</Note>
## The Two API Types
### Core API
Accessed on `/rest/` or `/graphql/`
Work with your actual **records** (the data):
- Create, read, update, delete People, Companies, Opportunities, etc.
- Query and filter data
- Manage record relationships
### Metadata API
Accessed on `/rest/metadata/` or `/metadata/`
Manage your **workspace and data model**:
- Create, modify, or delete objects and fields
- Configure workspace settings
- Define relationships between objects
## REST vs GraphQL
Both Core and Metadata APIs are available in REST and GraphQL formats:
| Format | Available Operations |
|--------|---------------------|
| **REST** | CRUD, batch operations, upserts |
| **GraphQL** | Same + **batch upserts**, relationship queries in one call |
Choose based on your needs — both formats access the same data.
## API Endpoints
| Environment | Base URL |
|-------------|----------|
| **Cloud** | `https://api.twenty.com/` |
| **Self-Hosted** | `https://{your-domain}/` |
## Authentication
Every API request requires an API key in the header:
```
Authorization: Bearer YOUR_API_KEY
```
### Create an API Key
1. Go to **Settings → APIs & Webhooks**
2. Click **+ Create key**
3. Configure:
- **Name**: Descriptive name for the key
- **Expiration Date**: When the key expires
4. Click **Save**
5. **Copy immediately** — the key is only shown once
<VimeoEmbed videoId="928786722" title="Creating API key" />
<Warning>
Your API key grants access to sensitive data. Don't share it with untrusted services. If compromised, disable it immediately and generate a new one.
</Warning>
### Assign a Role to an API Key
For better security, assign a specific role to limit access:
1. Go to **Settings → Members → Roles**
2. Click on the role to assign
3. Open the **Assignment** tab
4. Under **API Keys**, click **+ Assign to API key**
5. Select the API key
The key will inherit that role's permissions. See [Permissions](/user-guide/permissions-access/capabilities/permissions) for details.
### Manage API Keys
**Regenerate**: Settings → APIs & Webhooks → Click key → **Regenerate**
**Delete**: Settings → APIs & Webhooks → Click key → **Delete**
## API Playground
Test your APIs directly in the browser with our built-in playground — available for both **REST** and **GraphQL**.
### Access the Playground
1. Go to **Settings → APIs & Webhooks**
2. Create an API key (required)
3. Click on **REST API** or **GraphQL API** to open the playground
### What You Get
- **Interactive documentation**: Generated for your specific data model
- **Live testing**: Execute real API calls against your workspace
- **Schema explorer**: Browse available objects, fields, and relationships
- **Request builder**: Construct queries with autocomplete
The playground reflects your custom objects and fields, so documentation is always accurate for your workspace.
## Batch Operations
Both REST and GraphQL support batch operations:
- **Batch size**: Up to 60 records per request
- **Operations**: Create, update, delete multiple records
**GraphQL-only features:**
- **Batch Upsert**: Create or update in one call
- Use plural object names (e.g., `CreateCompanies` instead of `CreateCompany`)
## Rate Limits
API requests are throttled to ensure platform stability:
| Limit | Value |
|-------|-------|
| **Requests** | 100 calls per minute |
| **Batch size** | 60 records per call |
<Tip>
Use batch operations to maximize throughput — process up to 60 records in a single API call instead of making individual requests.
</Tip>

View File

@@ -0,0 +1,113 @@
---
title: Webhooks
description: Receive real-time notifications when events occur in your CRM.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Webhooks push data to your systems in real-time when events occur in Twenty — no polling required. Use them to keep external systems in sync, trigger automations, or send alerts.
## Create a Webhook
1. Go to **Settings → APIs & Webhooks → Webhooks**
2. Click **+ Create webhook**
3. Enter your webhook URL (must be publicly accessible)
4. Click **Save**
The webhook activates immediately and starts sending notifications.
<VimeoEmbed videoId="928786708" title="Creating a webhook" />
### Manage Webhooks
**Edit**: Click the webhook → Update URL → **Save**
**Delete**: Click the webhook → **Delete** → Confirm
## Events
Twenty sends webhooks for these event types:
| Event | Example |
|-------|---------|
| **Record Created** | `person.created`, `company.created`, `note.created` |
| **Record Updated** | `person.updated`, `company.updated`, `opportunity.updated` |
| **Record Deleted** | `person.deleted`, `company.deleted` |
All event types are sent to your webhook URL. Event filtering may be added in future releases.
## Payload Format
Each webhook sends an HTTP POST with a JSON body:
```json
{
"event": "person.created",
"data": {
"id": "abc12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"createdAt": "2025-02-10T15:30:45Z",
"createdBy": "user_123"
},
"timestamp": "2025-02-10T15:30:50Z"
}
```
| Field | Description |
|-------|-------------|
| `event` | What happened (e.g., `person.created`) |
| `data` | The full record that was created/updated/deleted |
| `timestamp` | When the event occurred (UTC) |
<Note>
Respond with a **2xx HTTP status** (200-299) to acknowledge receipt. Non-2xx responses are logged as delivery failures.
</Note>
## Webhook Validation
Twenty signs each webhook request for security. Validate signatures to ensure requests are authentic.
### Headers
| Header | Description |
|--------|-------------|
| `X-Twenty-Webhook-Signature` | HMAC SHA256 signature |
| `X-Twenty-Webhook-Timestamp` | Request timestamp |
### Validation Steps
1. Get the timestamp from `X-Twenty-Webhook-Timestamp`
2. Create the string: `{timestamp}:{JSON payload}`
3. Compute HMAC SHA256 using your webhook secret
4. Compare with `X-Twenty-Webhook-Signature`
### Example (Node.js)
```javascript
const crypto = require("crypto");
const timestamp = req.headers["x-twenty-webhook-timestamp"];
const payload = JSON.stringify(req.body);
const secret = "your-webhook-secret";
const stringToSign = `${timestamp}:${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(stringToSign)
.digest("hex");
const isValid = expectedSignature === req.headers["x-twenty-webhook-signature"];
```
## Webhooks vs Workflows
| Method | Direction | Use Case |
|--------|-----------|----------|
| **Webhooks** | OUT | Automatically notify external systems of any record change |
| **Workflow + HTTP Request** | OUT | Send data out with custom logic (filters, transformations) |
| **Workflow Webhook Trigger** | IN | Receive data into Twenty from external systems |
For receiving external data, see [Set Up a Webhook Trigger](/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger).

View File

@@ -0,0 +1,31 @@
---
title: Extend
description: Extend Twenty's functionality with APIs, webhooks, and custom apps.
---
<Frame>
<img src="/images/user-guide/integrations/plug.png" alt="AI" />
</Frame>
## Overview
Twenty is designed to be extensible. Use our APIs, webhooks, and app framework to integrate with your existing tools and build custom functionality.
## What You Can Do
- **APIs**: Query and modify your CRM data programmatically using REST or GraphQL
- **Webhooks**: Receive real-time notifications when events occur in Twenty
- **Apps**: Build custom applications that extend Twenty's capabilities
## Getting Started
<CardGroup cols={2}>
<Card title="APIs" icon="code" href="/developers/extend/api">
Connect to Twenty programmatically
</Card>
<Card title="Webhooks" icon="bell" href="/developers/extend/webhooks">
Get notified of events in real-time
</Card>
<Card title="Apps" icon="puzzle-piece" href="/developers/extend/apps/getting-started/quick-start">
Build customizations as code
</Card>
</CardGroup>

View File

@@ -0,0 +1,189 @@
---
title: OAuth
icon: "key"
description: Authorization code flow with PKCE and client credentials for server-to-server access.
---
Twenty implements OAuth 2.0 with authorization code + PKCE for user-facing apps and client credentials for server-to-server access. Clients are registered dynamically via [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) — no manual setup in a dashboard.
## When to Use OAuth
| Scenario | Auth Method |
|----------|-------------|
| Internal scripts, automation | [API Key](/developers/extend/api#authentication) |
| External app acting on behalf of a user | **OAuth — Authorization Code** |
| Server-to-server, no user context | **OAuth — Client Credentials** |
| Twenty App with UI extensions | [Apps](/developers/extend/apps/getting-started/quick-start) (OAuth is handled automatically) |
## Register a Client
Twenty supports **dynamic client registration** per [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591). No manual setup needed — register programmatically:
```bash
POST /oauth/register
Content-Type: application/json
{
"client_name": "My Integration",
"redirect_uris": ["https://myapp.com/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_post"
}
```
**Response:**
```json
{
"client_id": "abc123",
"client_secret": "secret456",
"client_name": "My Integration",
"redirect_uris": ["https://myapp.com/callback"]
}
```
<Warning>
Store the `client_secret` securely — it cannot be retrieved later.
</Warning>
## Scopes
| Scope | Access |
|-------|--------|
| `api` | Full read/write access to the Core and Metadata APIs |
| `profile` | Read the authenticated user's profile information |
Request scopes as a space-separated string: `scope=api profile`
## Authorization Code Flow
Use this flow when your app acts on behalf of a Twenty user.
### 1. Redirect the user to authorize
```
GET /oauth/authorize?
client_id=YOUR_CLIENT_ID&
response_type=code&
redirect_uri=https://myapp.com/callback&
scope=api&
state=random_state_value&
code_challenge=CHALLENGE&
code_challenge_method=S256
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `client_id` | Yes | Your registered client ID |
| `response_type` | Yes | Must be `code` |
| `redirect_uri` | Yes | Must match a registered redirect URI |
| `scope` | No | Space-separated scopes (defaults to `api`) |
| `state` | Recommended | Random string to prevent CSRF attacks |
| `code_challenge` | Recommended | PKCE challenge (SHA-256 hash of verifier, base64url-encoded) |
| `code_challenge_method` | Recommended | Must be `S256` when using PKCE |
The user sees a consent screen and approves or denies access.
### 2. Handle the callback
After authorization, Twenty redirects back to your `redirect_uri`:
```
https://myapp.com/callback?code=AUTH_CODE&state=random_state_value
```
Verify that `state` matches what you sent.
### 3. Exchange the code for tokens
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
code_verifier=YOUR_PKCE_VERIFIER
```
**Response:**
```json
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBh..."
}
```
### 4. Use the access token
```bash
GET /rest/companies
Authorization: Bearer ACCESS_TOKEN
```
### 5. Refresh when expired
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=YOUR_REFRESH_TOKEN&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
```
## Client Credentials Flow
For server-to-server integrations with no user interaction:
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
scope=api
```
The returned token has workspace-level access, not tied to any specific user.
## Server Discovery
Twenty publishes its OAuth configuration at a standard discovery endpoint:
```
GET /.well-known/oauth-authorization-server
```
This returns all endpoints, supported grant types, scopes, and capabilities — useful for building generic OAuth clients.
## API Endpoints Summary
| Endpoint | Purpose |
|----------|---------|
| `/.well-known/oauth-authorization-server` | Server metadata discovery |
| `/oauth/register` | Dynamic client registration |
| `/oauth/authorize` | User authorization |
| `/oauth/token` | Token exchange and refresh |
| Environment | Base URL |
|-------------|----------|
| **Cloud** | `https://api.twenty.com` |
| **Self-Hosted** | `https://{your-domain}` |
## OAuth vs API Keys
| | API Keys | OAuth |
|-|----------|-------|
| **Setup** | Generate in Settings | Register a client, implement flow |
| **User context** | None (workspace-level) | Specific user's permissions |
| **Best for** | Scripts, internal tools | External apps, multi-user integrations |
| **Token rotation** | Manual | Automatic via refresh tokens |
| **Scoped access** | Full API access | Granular via scopes |

View File

@@ -0,0 +1,117 @@
---
title: Webhooks
icon: "satellite-dish"
description: Get notified when records change — HTTP POST to your endpoint on every create, update, or delete.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Twenty sends an HTTP POST to your URL whenever a record is created, updated, or deleted. All object types are covered, including custom objects.
## Create a Webhook
1. Go to **Settings → APIs & Webhooks → Webhooks**
2. Click **+ Create webhook**
3. Enter your webhook URL (must be publicly accessible)
4. Click **Save**
The webhook activates immediately and starts sending notifications.
<VimeoEmbed videoId="928786708" title="Creating a webhook" />
### Manage Webhooks
**Edit**: Click the webhook → Update URL → **Save**
**Delete**: Click the webhook → **Delete** → Confirm
## Events
Twenty sends webhooks for these event types:
| Event | Example |
|-------|---------|
| **Record Created** | `person.created`, `company.created`, `note.created` |
| **Record Updated** | `person.updated`, `company.updated`, `opportunity.updated` |
| **Record Deleted** | `person.deleted`, `company.deleted` |
All event types are sent to your webhook URL. Event filtering may be added in future releases.
## Payload Format
Each webhook sends an HTTP POST with a JSON body:
```json
{
"event": "person.created",
"data": {
"id": "abc12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"createdAt": "2025-02-10T15:30:45Z",
"createdBy": "user_123"
},
"timestamp": "2025-02-10T15:30:50Z"
}
```
| Field | Description |
|-------|-------------|
| `event` | What happened (e.g., `person.created`) |
| `data` | The full record that was created/updated/deleted |
| `timestamp` | When the event occurred (UTC) |
<Note>
Respond with a **2xx HTTP status** (200-299) to acknowledge receipt. Non-2xx responses are logged as delivery failures.
</Note>
## Webhook Validation
Twenty signs each webhook request for security. Validate signatures to ensure requests are authentic.
### Headers
| Header | Description |
|--------|-------------|
| `X-Twenty-Webhook-Signature` | HMAC SHA256 signature |
| `X-Twenty-Webhook-Timestamp` | Request timestamp |
### Validation Steps
1. Get the timestamp from `X-Twenty-Webhook-Timestamp`
2. Create the string: `{timestamp}:{JSON payload}`
3. Compute HMAC SHA256 using your webhook secret
4. Compare with `X-Twenty-Webhook-Signature`
### Example (Node.js)
```javascript
const crypto = require("crypto");
const timestamp = req.headers["x-twenty-webhook-timestamp"];
const payload = JSON.stringify(req.body);
const secret = "your-webhook-secret";
const stringToSign = `${timestamp}:${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(stringToSign)
.digest("hex");
const receivedSignature = req.headers["x-twenty-webhook-signature"];
const isValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(receivedSignature, "hex")
);
```
## Webhooks vs Workflows
| Method | Direction | Use Case |
|--------|-----------|----------|
| **Webhooks** | OUT | Automatically notify external systems of any record change |
| **Workflow + HTTP Request** | OUT | Send data out with custom logic (filters, transformations) |
| **Workflow Webhook Trigger** | IN | Receive data into Twenty from external systems |
For receiving external data, see [Set Up a Webhook Trigger](/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger).

View File

@@ -0,0 +1,28 @@
---
title: Developers
description: Build apps, use the API, self-host, or contribute to the codebase.
---
import { CardTitle } from "/snippets/card-title.mdx"
<CardGroup cols={3}>
<Card href="/developers/extend/apps/getting-started/quick-start" img="/images/user-guide/halftone/dev-apps.png">
<CardTitle>Apps</CardTitle>
Extend Twenty with custom objects, server-side logic, UI components, and AI agents — all as TypeScript packages.
</Card>
<Card href="/developers/extend/api" img="/images/user-guide/halftone/dev-api.png">
<CardTitle>API</CardTitle>
REST and GraphQL APIs, webhooks, and OAuth.
</Card>
<Card href="/developers/self-host/capabilities/docker-compose" img="/images/user-guide/halftone/dev-self-host.png">
<CardTitle>Self-Host</CardTitle>
Run Twenty on your own infrastructure.
</Card>
<Card href="/developers/contribute/capabilities/local-setup" img="/images/user-guide/halftone/dev-contribute.png">
<CardTitle>Contribute</CardTitle>
Set up the monorepo locally and submit PRs.
</Card>
</CardGroup>

View File

@@ -0,0 +1,255 @@
---
title: Docker Compose
icon: "docker"
---
<Warning>
Docker containers are for production hosting or self-hosting. For contributing, please check the [Local Setup](/developers/contribute/capabilities/local-setup).
</Warning>
## Overview
This guide provides step-by-step instructions to install and configure the Twenty application using Docker Compose. The aim is to make the process straightforward and prevent common pitfalls that could break your setup.
**Important:** Only modify settings explicitly mentioned in this guide. Altering other configurations may lead to issues.
See [Setup Environment Variables](/developers/self-host/capabilities/setup) for advanced configuration. All environment variables must be declared in the `docker-compose.yml` file at the server and/or worker level, depending on the variable.
## System Requirements
- RAM: Ensure your environment has at least 2GB of RAM. Insufficient memory can cause processes to crash.
- Docker & Docker Compose: Make sure both are installed and up-to-date.
## Option 1: One-line script
Install the latest stable version of Twenty with a single command:
```bash
bash <(curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh)
```
To install a specific version:
```bash
VERSION=v2.38.1 bash <(curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh)
```
- `VERSION` must be a full release tag published on [Docker Hub](https://hub.docker.com/r/twentycrm/twenty/tags), such as `v2.38.1`. Aliases such as `v2` or `latest` are not accepted.
- The matching `docker-compose.yml` is fetched from the `twenty/<version>` git tag, so releases that predate these tags (v2.9.0 and older) can't be installed with the script.
To fetch the configuration files from a branch instead, for example to test unreleased changes:
```bash
BRANCH=branch-name bash <(curl -sL https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh)
```
- Replace branch-name with the branch to fetch `docker-compose.yml` and `.env.example` from. The image still defaults to the latest release unless `VERSION` is also set.
## Option 2: Manual steps
Follow these steps for a manual setup.
### Step 1: Set Up the Environment File
1. **Create the .env File**
Copy the example environment file to a new .env file in your working directory:
```bash
curl -o .env https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-docker/.env.example
```
2. **Generate an Encryption Key**
Run the following command to generate a unique random string:
```bash
openssl rand -base64 32
```
**Important:** Keep this value secret / do not share it. Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database (OAuth tokens, application variables, TOTP secrets, etc.).
3. **Update the `.env`**
Replace the placeholder value in your .env file with the generated token:
```ini
ENCRYPTION_KEY=random_string
```
See the [Key rotation guide](/developers/self-host/capabilities/key-rotation) for instructions on rotating it without downtime.
4. **Set the Postgres Password**
Update the `PG_DATABASE_PASSWORD` value in the .env file with a strong password without special characters.
```ini
PG_DATABASE_PASSWORD=my_strong_password
```
### Step 2: Obtain the Docker Compose File
Download the `docker-compose.yml` file to your working directory:
```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-docker/docker-compose.yml
```
### Step 3: Launch the Application
Start the Docker containers:
```bash
docker compose up -d
```
### Step 4: Access the Application
If you host twentyCRM on your own computer, open your browser and navigate to [http://localhost:3000](http://localhost:3000).
If you host it on a server, check that the server is running and that everything is ok with
```bash
curl http://localhost:3000
```
## Configuration
### Expose Twenty to External Access
By default, Twenty runs on `localhost` at port `3000`. The UI resolves the API from the browser's own origin, so it loads from any hostname that reaches the container — but still set `SERVER_URL` to your public URL: the server uses it for generated links (emails, OAuth callbacks) and to detect HTTPS for secure cookies.
#### Understanding `SERVER_URL`
- **Protocol:** Use `http` or `https` depending on your setup.
- Use `http` if you haven't set up SSL.
- Use `https` if you have SSL configured.
- **Domain/IP:** This is the domain name or IP address where your application is accessible.
- **Port:** Include the port number if you're not using the default ports (`80` for `http`, `443` for `https`).
### SSL Requirements
SSL (HTTPS) is required for certain browser features to work properly. While these features might work during local development (as browsers treat localhost differently), a proper SSL setup is needed when hosting Twenty on a regular domain.
For example, the clipboard API might require a secure context - some features like copy buttons throughout the application might not work without HTTPS enabled.
We strongly recommend setting up Twenty behind a reverse proxy with SSL termination for optimal security and functionality.
#### Configuring `SERVER_URL`
1. **Determine Your Access URL**
- **Without Reverse Proxy (Direct Access):**
If you're accessing the application directly without a reverse proxy:
```ini
SERVER_URL=http://your-domain-or-ip:3000
```
- **With Reverse Proxy (Standard Ports):**
If you're using a reverse proxy like Nginx or Traefik and have SSL configured:
```ini
SERVER_URL=https://your-domain-or-ip
```
- **With Reverse Proxy (Custom Ports):**
If you're using non-standard ports:
```ini
SERVER_URL=https://your-domain-or-ip:custom-port
````
2. **Update the `.env` File**
Open your `.env` file and update the `SERVER_URL`:
```ini
SERVER_URL=http(s)://your-domain-or-ip:your-port
```
**Examples:**
- Direct access without SSL:
```ini
SERVER_URL=http://123.45.67.89:3000
```
- Access via domain with SSL:
```ini
SERVER_URL=https://mytwentyapp.com
```
3. **Restart the Application**
For changes to take effect, restart the Docker containers:
```bash
docker compose down
docker compose up -d
```
#### Considerations
- **Reverse Proxy Configuration:**
Ensure your reverse proxy forwards requests to the correct internal port (`3000` by default). Configure SSL termination and any necessary headers.
- **Firewall Settings:**
Open necessary ports in your firewall to allow external access.
- **Consistency:**
The `SERVER_URL` must match how users access your application in their browsers.
#### Persistence
- **Data Volumes:**
The Docker Compose configuration uses volumes to persist data for the database and server storage.
- **Stateless Environments:**
If deploying to a stateless environment (e.g., certain cloud services), configure external storage to persist data.
## Backup and Restore
Regular backups protect your CRM data from loss.
### Create a Database Backup
```bash
docker exec twenty-postgres pg_dump -U postgres twenty > backup_$(date +%Y%m%d).sql
```
### Automate Daily Backups
Add to your crontab (`crontab -e`):
```bash
0 2 * * * docker exec twenty-postgres pg_dump -U postgres twenty > /backups/twenty_$(date +\%Y\%m\%d).sql
```
### Restore from Backup
1. Stop the application:
```bash
docker compose stop server worker
```
2. Restore the database:
```bash
docker exec -i twenty-postgres psql -U postgres twenty < backup_20240115.sql
```
3. Restart services:
```bash
docker compose up -d
```
### Backup Best Practices
- **Test restores regularly** — verify backups actually work
- **Store backups off-site** — use cloud storage (S3, GCS, etc.)
- **Encrypt sensitive data** — protect backups with encryption
- **Retain multiple copies** — keep daily, weekly, and monthly backups
## Troubleshooting
If you encounter any problem, check [Troubleshooting](/developers/self-host/capabilities/troubleshooting) for solutions.
## Managed Hosting
<Tip>
Prefer not to run and maintain Twenty yourself? [Find a certified Twenty hosting partner](https://twenty.com/partners/list?categories=HOSTING&ref=docs-docker-compose) to deploy, host, and manage your instance end-to-end. *(Or reach our team: [contact@twenty.com](mailto:contact@twenty.com).)*
</Tip>

View File

@@ -0,0 +1,56 @@
---
title: Key rotation
icon: "rotate"
---
Twenty has two independent key families:
- **JWT signing keys** — asymmetric ES256 keypairs (`kid`-tagged) stored in `core."signingKey"`, used to sign and verify access / refresh tokens.
- **At-rest encryption key** — `ENCRYPTION_KEY`, used to encrypt OAuth tokens, application variables, signing-key private keys, sensitive config values and TOTP secrets inside an `enc:v2:` envelope.
`APP_SECRET` is a legacy secret kept for backward compatibility: when `ENCRYPTION_KEY` is unset it acts as the at-rest encryption / session cookie fallback, and it still verifies pre-existing HS256 access tokens. It will be deprecated.
## JWT signing keys
Each key carries a `publicKey` (kept indefinitely so it can verify previously issued tokens), an encrypted `privateKey` (used only while the key is current), an `isCurrent` flag (exactly one row at a time) and an optional `revokedAt`.
### Rotate the current key
Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation.
<Note>Auto-rotation ships in v2.6+.</Note>
### Revoke a key (leak / emergency only)
**Settings → Admin Panel → Signing keys → Revoke** on a non-current row. Wipes the encrypted private material, sets `revokedAt`, and rejects every existing token signed under that `kid`.
## Rotate `ENCRYPTION_KEY`
<Note>The `secret-encryption:rotate` command described below ships in v2.6+.</Note>
Every encrypted value is wrapped as `enc:v2:<keyId>:<payload>`, where `<keyId>` is an 8-hex prefix derived from the raw key. Rotation is online and resumable.
1. **Generate a new key**: `openssl rand -base64 32`.
2. **Configure both keys side-by-side** in `.env`, then restart:
```ini
ENCRYPTION_KEY=NEW_VALUE
FALLBACK_ENCRYPTION_KEY=OLD_VALUE
```
New writes use the new key, existing rows still decrypt via the fallback.
3. **Re-encrypt existing rows**:
```bash
docker exec -it {server_container} yarn command:prod secret-encryption:rotate
```
The command walks six sites (`connected-account-tokens`, `application-variable`, `application-registration-variable`, `signing-key-private-keys`, `sensitive-config-storage`, `totp-secrets`). A SQL filter skips rows already on the new `<keyId>`, so the command is idempotent: interrupt and re-run as needed. Exits non-zero if any row fails — re-run to retry.
| Flag | Description |
| --- | --- |
| `-s, --site <site>` | Limit to a single site. |
| `-b, --batch-size <n>` | Rows per batch (default `200`, max `5000`). |
| `-d, --dry-run` | Decrypt + re-encrypt in memory, skip the `UPDATE`. |
4. **Drop the fallback** once `--dry-run` shows zero remaining rows: remove `FALLBACK_ENCRYPTION_KEY` and restart.
## Legacy `APP_SECRET` support
Older instances that never set `ENCRYPTION_KEY` use `APP_SECRET` as the at-rest encryption key (and as the session-cookie secret, derived from it). This path is preserved for backward compatibility but is **deprecated** — set a dedicated `ENCRYPTION_KEY` and follow the rotation procedure above to migrate off it. `APP_SECRET` itself stays in use to verify legacy HS256 access tokens.

View File

@@ -0,0 +1,416 @@
---
title: Setup
icon: "gear"
---
# Configuration Management
<Warning>
**First time installing?** Follow the [Docker Compose installation guide](/developers/self-host/capabilities/docker-compose) to get Twenty running, then return here for configuration.
</Warning>
Twenty offers **two configuration modes** to suit different deployment needs:
**Admin panel access:** Only users with admin privileges (`canAccessFullAdminPanel: true`) can access the configuration interface.
## 1. Admin Panel Configuration (Default)
```bash
IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # default
```
**Most configuration happens through the UI** after installation:
1. Access your Twenty instance (usually `http://localhost:3000`)
2. Go to **Settings / Admin Panel / Configuration Variables**
3. Configure integrations, email, storage, and more
4. Changes take effect immediately (within 15 seconds for multi-container deployments)
<Warning>
**Multi-Container Deployments:** When using database configuration (`IS_CONFIG_VARIABLES_IN_DB_ENABLED=true`), both server and worker containers read from the same database. Admin panel changes affect both automatically, eliminating the need to duplicate environment variables between containers (except for infrastructure variables).
</Warning>
**What you can configure through the admin panel:**
- **Authentication** - Google/Microsoft OAuth, password settings
- **Email** - SMTP settings, templates, verification
- **Storage** - S3 configuration, local storage paths
- **Integrations** - Gmail, Google Calendar, Microsoft services
- **Workflow & Rate Limiting** - Execution limits, API throttling
- **And much more...**
![Admin Panel Configuration Variables](/images/user-guide/setup/admin-panel-config-variables.png)
<Warning>
Each variable is documented with descriptions in your admin panel at **Settings → Admin Panel → Configuration Variables**.
Some infrastructure settings like database connections (`PG_DATABASE_URL`), server URLs (`SERVER_URL`), and secrets (`ENCRYPTION_KEY`, `FALLBACK_ENCRYPTION_KEY`) can only be configured via `.env` file.
[Complete technical reference →](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts)
</Warning>
## Encryption keys
Twenty uses two env-only encryption keys:
| Variable | Purpose | Required |
| --- | --- | --- |
| `ENCRYPTION_KEY` | Primary key used to encrypt secrets at rest (OAuth tokens, application variables, signing-key private keys, TOTP secrets, sensitive config values). | Yes for new installs (legacy installs may instead rely on `APP_SECRET` — see below) |
| `FALLBACK_ENCRYPTION_KEY` | Verification-only key. Set during a rotation to the *previous* `ENCRYPTION_KEY` so existing rows remain decryptable. | Only during rotation |
For backward compatibility, if `ENCRYPTION_KEY` is unset, Twenty falls back to `APP_SECRET` for at-rest encryption — matching the legacy behaviour for older deployments. New installs should always set a dedicated `ENCRYPTION_KEY`.
Generate values with `openssl rand -base64 32` and store them somewhere safe (a secrets manager, sealed config, etc.). Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database.
To rotate `ENCRYPTION_KEY` without downtime, see the [Key rotation guide](/developers/self-host/capabilities/key-rotation).
## 2. Environment-Only Configuration
```bash
IS_CONFIG_VARIABLES_IN_DB_ENABLED=false
```
**All configuration managed through `.env` files:**
1. Set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false` in your `.env` file
2. Add all configuration variables to your `.env` file
3. Restart containers for changes to take effect
4. Admin panel will show current values but cannot modify them
## Multi-Workspace Mode
By default, Twenty runs in **single-workspace mode** — ideal for most self-hosted deployments where you need one CRM instance for your organization.
### Single-Workspace Mode (Default)
```bash
IS_MULTIWORKSPACE_ENABLED=false # default
```
- One workspace per Twenty instance
- First user automatically becomes admin with full privileges (`canImpersonate` and `canAccessFullAdminPanel`)
- New signups are disabled after the first workspace is created
- Simple URL structure: `https://your-domain.com`
### Enabling Multi-Workspace Mode
```bash
IS_MULTIWORKSPACE_ENABLED=true
DEFAULT_SUBDOMAIN=app # default value
```
Enable multi-workspace mode for SaaS-like deployments where multiple independent teams need their own workspaces on the same Twenty instance.
**Key differences from single-workspace mode:**
- Multiple workspaces can be created on the same instance
- Each workspace gets its own subdomain (e.g., `sales.your-domain.com`, `marketing.your-domain.com`)
- Users sign up and log in at `{DEFAULT_SUBDOMAIN}.your-domain.com` (e.g., `app.your-domain.com`)
- No automatic admin privileges — first user in each workspace is a regular user
- Workspace-specific settings like subdomain and custom domain become available in workspace settings
<Warning>
**Environment-only setting:** `IS_MULTIWORKSPACE_ENABLED` can only be configured via `.env` file and requires a restart. It cannot be changed through the admin panel.
</Warning>
### DNS Configuration for Multi-Workspace
When using multi-workspace mode, configure your DNS with a wildcard record to allow dynamic subdomain creation:
```
*.your-domain.com -> your-server-ip
```
This enables automatic subdomain routing for new workspaces without manual DNS configuration.
### Restricting Workspace Creation
In multi-workspace mode, you may want to limit who can create new workspaces:
```bash
IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true
```
When enabled, only users with `canAccessFullAdminPanel` can create additional workspaces. Users can still create their first workspace during initial signup.
## Gmail & Google Calendar Integration
### Create Google Cloud Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing one
3. Enable these APIs:
- [Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com)
- [Google Calendar API](https://console.cloud.google.com/apis/library/calendar-json.googleapis.com)
- [People API](https://console.cloud.google.com/apis/library/people.googleapis.com)
### Configure OAuth
1. Go to [Credentials](https://console.cloud.google.com/apis/credentials)
2. Create OAuth 2.0 Client ID
3. Add these redirect URIs:
- `https://{your-domain}/auth/google/redirect` (for SSO)
- `https://{your-domain}/auth/google-apis/get-access-token` (for integrations)
### Configure in Twenty
1. Go to **Settings → Admin Panel → Configuration Variables**
2. Find the **Google Auth** section
3. Set these variables:
- `MESSAGING_PROVIDER_GMAIL_ENABLED=true`
- `CALENDAR_PROVIDER_GOOGLE_ENABLED=true`
- `AUTH_GOOGLE_CLIENT_ID={client-id}`
- `AUTH_GOOGLE_CLIENT_SECRET={client-secret}`
- `AUTH_GOOGLE_CALLBACK_URL=https://{your-domain}/auth/google/redirect`
- `AUTH_GOOGLE_APIS_CALLBACK_URL=https://{your-domain}/auth/google-apis/get-access-token`
<Warning>
**Environment-only mode:** If you set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`, add these variables to your `.env` file instead.
</Warning>
**Required scopes** (automatically configured):
[See relevant source code](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes.ts#L4-L10)
- `https://www.googleapis.com/auth/calendar.events`
- `https://www.googleapis.com/auth/gmail.readonly`
- `https://www.googleapis.com/auth/profile.emails.read`
### If your app is in test mode
If your app is in test mode, you will need to add test users to your project.
Under [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent), add your test users to the "Test users" section.
## Microsoft 365 Integration
<Warning>
Users must have a [Microsoft 365 Licence](https://admin.microsoft.com/Adminportal/Home) to be able to use the Calendar and Messaging API. They will not be able to sync their account on Twenty without one.
</Warning>
### Create a project in Microsoft Azure
You will need to create a project in [Microsoft Azure](https://portal.azure.com/#view/Microsoft_AAD_IAM/AppGalleryBladeV2) and get the credentials.
### Enable APIs
On Microsoft Azure Console enable the following APIs in "Permissions":
- Microsoft Graph: Mail.ReadWrite
- Microsoft Graph: Mail.Send
- Microsoft Graph: Calendars.Read
- Microsoft Graph: User.Read
- Microsoft Graph: openid
- Microsoft Graph: email
- Microsoft Graph: profile
- Microsoft Graph: offline_access
<Note>
"Mail.ReadWrite" and "Mail.Send" are only mandatory if you want to send emails using our workflow actions. You can use "Mail.Read" instead if you only want to receive emails.
</Note>
<Note>
"Calendars.ReadWrite" is mandatory if you want to create calendar events using our workflow actions. If you want to only sync calendar events, use "Calendars.Read".
</Note>
### Authorized redirect URIs
You need to add the following redirect URIs to your project:
- `https://{your-domain}/auth/microsoft/redirect` if you want to use Microsoft SSO
- `https://{your-domain}/auth/microsoft-apis/get-access-token`
### Configure in Twenty
1. Go to **Settings → Admin Panel → Configuration Variables**
2. Find the **Microsoft Auth** section
3. Set these variables:
- `MESSAGING_PROVIDER_MICROSOFT_ENABLED=true`
- `CALENDAR_PROVIDER_MICROSOFT_ENABLED=true`
- `AUTH_MICROSOFT_ENABLED=true`
- `AUTH_MICROSOFT_CLIENT_ID={client-id}`
- `AUTH_MICROSOFT_CLIENT_SECRET={client-secret}`
- `AUTH_MICROSOFT_CALLBACK_URL=https://{your-domain}/auth/microsoft/redirect`
- `AUTH_MICROSOFT_APIS_CALLBACK_URL=https://{your-domain}/auth/microsoft-apis/get-access-token`
<Warning>
**Environment-only mode:** If you set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`, add these variables to your `.env` file instead.
</Warning>
### Configure scopes
[See relevant source code](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/utils/get-microsoft-apis-oauth-scopes.ts#L2-L9)
- 'openid'
- 'email'
- 'profile'
- 'offline_access'
- 'Mail.ReadWrite'
- 'Mail.Send'
- 'Calendars.Read'
### If your app is in test mode
If your app is in test mode, you will need to add test users to your project.
Add your test users to the "Users and groups" section.
## Background Jobs for Calendar & Messaging
After configuring Gmail, Google Calendar, or Microsoft 365 integrations, you need to start the background jobs that sync data.
Register the following recurring jobs in your worker container:
```bash
# from your worker container
yarn command:prod cron:messaging:messages-import
yarn command:prod cron:messaging:message-list-fetch
yarn command:prod cron:calendar:calendar-event-list-fetch
yarn command:prod cron:calendar:calendar-events-import
yarn command:prod cron:messaging:ongoing-stale
yarn command:prod cron:calendar:ongoing-stale
yarn command:prod cron:workflow:automated-cron-trigger
```
## Outbound Connections to Internal Hosts
By default, Twenty refuses outbound connections to private/internal IP addresses (SSRF protection). This covers HTTP workflow actions, webhooks, SSO identity providers and IMAP/SMTP/CalDAV connections. If your identity provider, mail or calendar server runs on a private network, list its hostname or IP literal:
```bash
OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS=keycloak,mail.internal,192.168.1.10
```
Entries are matched on the hostname only, without wildcards: for an OIDC issuer such as `http://keycloak:8080/realms/twenty`, list `keycloak`. Every other private address stays blocked, and even an allowed host can never reach the cloud metadata address range. Set the variable to `*` to disable the protection entirely, which is only appropriate on trusted, isolated networks.
`OUTBOUND_HTTP_SAFE_MODE_ENABLED` is deprecated. While it is set to `false`, the protection stays fully off and `OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS` is ignored, so remove it when you migrate to the list.
## Email Configuration
1. Go to **Settings → Admin Panel → Configuration Variables**
2. Find the **Email** section
3. Configure your SMTP settings:
<Tabs>
<Tab title="Gmail">
You will need to provision an [App Password](https://support.google.com/accounts/answer/185833).
- EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=smtp.gmail.com
- EMAIL_SMTP_PORT=465
- EMAIL_SMTP_USER=gmail_email_address
- EMAIL_SMTP_PASSWORD='gmail_app_password'
</Tab>
<Tab title="Office365">
Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
- EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=smtp.office365.com
- EMAIL_SMTP_PORT=587
- EMAIL_SMTP_USER=office365_email_address
- EMAIL_SMTP_PASSWORD='office365_password'
</Tab>
<Tab title="smtp4dev">
**smtp4dev** is a fake SMTP email server for development and testing.
- Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
- Set the following variables:
- EMAIL_DRIVER=smtp
- EMAIL_SMTP_HOST=localhost
- EMAIL_SMTP_PORT=2525
</Tab>
</Tabs>
<Warning>
**Environment-only mode:** If you set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`, add these variables to your `.env` file instead.
</Warning>
## S3 Storage
<Warning>
By default, Twenty stores uploaded files on the local filesystem. For production deployments, use S3 or an S3-compatible service (MinIO, DigitalOcean Spaces, etc.) to ensure files persist across container restarts and scale across multiple server instances.
</Warning>
Set `STORAGE_TYPE=S_3` and configure the `STORAGE_S3_*` variables through the admin panel or `.env`. See the [config-variables.ts reference](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts) for the full list of S3 variables.
When using S3 with CORS-dependent features (e.g. in-browser file downloads), make sure your bucket allows your Twenty frontend origin in its CORS configuration.
## Logic Functions & Code Interpreter
Twenty supports logic functions for workflows and the code interpreter for AI data analysis. Both run user-provided code and require explicit configuration for security.
### Security Defaults
**In production (NODE_ENV=production):** Both logic functions and code interpreter default to **Disabled**. You must explicitly enable them with `LOGIC_FUNCTION_TYPE` and `CODE_INTERPRETER_TYPE` if you need these features.
**In development (NODE_ENV=development):** Both default to **LOCAL** for convenience when running locally.
<Warning>
**Security Notice:** The local driver (`LOGIC_FUNCTION_TYPE=LOCAL` or `CODE_INTERPRETER_TYPE=LOCAL`) runs code directly on the host in a Node.js process with no sandboxing. It should only be used for trusted code in development. For production deployments handling untrusted code, use `LOGIC_FUNCTION_TYPE=LAMBDA` or `CODE_INTERPRETER_TYPE=E2B` (with sandboxing), or keep them disabled.
</Warning>
### Logic Functions - Available Drivers
| Driver | Environment Variable | Use Case | Security Level |
|--------|---------------------|----------|----------------|
| Disabled | `LOGIC_FUNCTION_TYPE=DISABLED` | Disable logic functions entirely | N/A |
| Local | `LOGIC_FUNCTION_TYPE=LOCAL` | Development and trusted environments | Low (no sandboxing) |
| Lambda | `LOGIC_FUNCTION_TYPE=LAMBDA` | Production with untrusted code | High (hardware-level isolation) |
### Logic Functions - Recommended Configuration
**For development:**
```bash
LOGIC_FUNCTION_TYPE=LOCAL # default when NODE_ENV=development
```
**For production (AWS):**
```bash
LOGIC_FUNCTION_TYPE=LAMBDA
LOGIC_FUNCTION_LAMBDA_REGION=us-east-1
LOGIC_FUNCTION_LAMBDA_ROLE=arn:aws:iam::123456789:role/your-lambda-role
LOGIC_FUNCTION_LAMBDA_ACCESS_KEY_ID=your-access-key
LOGIC_FUNCTION_LAMBDA_SECRET_ACCESS_KEY=your-secret-key
```
**To disable logic functions:**
```bash
LOGIC_FUNCTION_TYPE=DISABLED # default when NODE_ENV=production
```
### Code Interpreter - Available Drivers
| Driver | Environment Variable | Use Case | Security Level |
|--------|---------------------|----------|----------------|
| Disabled | `CODE_INTERPRETER_TYPE=DISABLED` | Disable AI code execution | N/A |
| Local | `CODE_INTERPRETER_TYPE=LOCAL` | Development only | Low (no sandboxing) |
| E2B | `CODE_INTERPRETER_TYPE=E_2_B` | Production with sandboxed execution | High (isolated sandbox) |
<Note>
When using `LOGIC_FUNCTION_TYPE=DISABLED` or `CODE_INTERPRETER_TYPE=DISABLED`, any attempt to execute will return an error. This is useful if you want to run Twenty without these capabilities.
</Note>
## API and front-end on separate hosts
Only relevant if you do not let the back-end serve the front-end. Same-origin
deployments need nothing here.
Declare every browser origin that talks to the API, comma separated. `SERVER_URL`
and `FRONTEND_URL` are already trusted. The server logs a warning when a browser
calls it from an undeclared origin.
```bash
AUTH_COOKIE_ALLOWED_ORIGINS=https://app.example.com
```
<Warning>
The front-end sends credentials on every request, and browsers reject a
credentialed response from an undeclared origin, so the app fails to load until
you set this.
</Warning>
If the two hosts are on different registrable domains (not just different
subdomains), also set `AUTH_COOKIE_SAME_SITE=none`, which requires HTTPS.
With multi-workspace enabled, do not list every workspace subdomain here.
Instead, route API paths (`/graphql`, `/metadata`, `/rest`, `/auth/`,
`/client-config`, `/file`) to the back-end on each workspace hostname, so every
workspace stays same-origin — this is how Twenty Cloud runs.

View File

@@ -0,0 +1,242 @@
---
title: Troubleshooting
icon: "wrench"
---
## Troubleshooting
If you encounter any problem while setting up environment for development, upgrading your instance or self-hosting,
here are some solutions for common problems.
### Self-hosting
#### First install results in `password authentication failed for user "postgres"`
🚨 **IMPORTANT: This solution is ONLY for fresh installations** 🚨
If you have an existing Twenty instance with production data, **DO NOT** follow these steps as they will permanently delete your database!
While installing Twenty for the first time, you might want to change the default database password.
The password you set during the first installation becomes permanently stored in the database volume. If you later try to change this password in your configuration without removing the old volume, you'll get authentication errors because the database is still using the original password.
⚠️ WARNING: Following steps will PERMANENTLY DELETE all database data! ⚠️
Only proceed if this is a fresh installation with no important data.
To update the `PG_DATABASE_PASSWORD`, you need to:
```sh
# Update the PG_DATABASE_PASSWORD in .env
docker compose down --volumes
docker compose up -d
```
#### CR line breaks found [Windows]
This happens because of Windows line-ending characters combined with your Git configuration. Try running:
```
git config --global core.autocrlf false
```
Then delete the repository and clone it again.
#### Missing metadata schema
During Twenty installation, you need to provision your postgres database with the right schemas, extensions, and users.
If you're successful in running this provisioning, you should have `default` and `metadata` schemas in your database.
If you don't, make sure you don't have more than one PostgreSQL instance running on your computer.
#### Cannot find module 'twenty-emails' or its corresponding type declarations.
You have to build the `twenty-emails` package (`npx nx run twenty-emails:build`) before running the database initialization.
#### Missing twenty-x package
Make sure to run yarn in the root directory and then run `npx nx server:dev twenty-server`. If this still doesn't work try building the missing package manually.
#### Lint on Save not working
This should work out of the box with the Oxc extension (`oxc.oxc-vscode`) installed. If this doesn't work, try adding this to your VS Code settings (dev container scope):
```
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
}
```
#### While running `npx nx start` or `npx nx start twenty-front`, an out-of-memory error is thrown
Run only the services you need, instead of `npx nx start`. For instance, if you work on the server, run only `npx nx worker twenty-server`
**If it does not work:**
If you tried to run only `npx nx run twenty-server:start` on WSL and it's failing with the below memory error:
`FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory`
A workaround is to run the following command in your terminal, or add it to your `.bashrc` profile so it's applied automatically:
`export NODE_OPTIONS="--max-old-space-size=8192"`
The --max-old-space-size=8192 flag sets an upper limit of 8GB for the Node.js heap; usage scales with application demand.
Reference: https://stackoverflow.com/questions/56982005/where-do-i-set-node-options-max-old-space-size-2048
**If it does not work:**
Investigate which processes are taking up most of your machine's RAM. At Twenty, we noticed some VS Code extensions were taking up a lot of RAM, so we temporarily disabled them.
**If it does not work:**
Restarting your machine helps clean up ghost processes.
#### While running `npx nx start` there are weird [0] and [1] in logs
That's expected as command `npx nx start` is running more commands under the hood
#### No emails are sent
Most of the time, it's because the `worker` is not running in the background. Try to run
```
npx nx worker twenty-server
```
#### Cannot connect my Microsoft 365 account to Twenty
Most of the time, it's because your admin has not enabled the Microsoft 365 Licence for your account. Check [https://admin.microsoft.com/](https://admin.microsoft.com/Adminportal/Home).
If you have an error code `AADSTS50020`, it probably means that you are using a personal Microsoft account. This is not supported yet. More info [here](https://learn.microsoft.com/fr-fr/troubleshoot/entra/entra-id/app-integration/error-code-aadsts50020-user-account-identity-provider-does-not-exist)
#### While running `yarn`, warnings appear in the console
Warnings are informing about pulling additional dependencies which aren't explicitly stated in `package.json`, so as long as no breaking error appears, everything should work as expected.
#### When a user accesses the login page, an error about an unauthorized user trying to access a workspace appears in the logs
That's expected as the user is unauthorized when logged out, since their identity has not been verified.
#### How to check if your worker is running?
- Go to [webhook-test.com](https://webhook-test.com/) and copy **Your Unique Webhook URL**.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/self-hosting/webhook-test.jpg" alt="Webhook test" />
</div>
- Open your Twenty app, navigate to `/settings`, and enable the **Advanced** toggle at the bottom left of the screen.
- Create a new webhook.
- Paste **Your Unique Webhook URL** in the **Endpoint Url** field in Twenty. Set the **Filters** to `Companies` and `Created`.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/self-hosting/webhook-settings.jpg" alt="Webhook settings" />
</div>
- Go to `/objects/companies` and create a new company record.
- Return to [webhook-test.com](https://webhook-test.com/) and check if a new **POST request** has been received.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/self-hosting/webhook-test-result.jpg" alt="Webhook test result" />
</div>
- If a **POST request** is received, your worker is running successfully. Otherwise, you need to troubleshoot your worker.
#### Front-end fails to start and returns error TS5042: Option 'project' cannot be mixed with source files on the command line
Comment out the checker plugin in `packages/twenty-ui/vite-config.ts`, as shown in the example below:
```
plugins: [
react({ jsxImportSource: 'react' }),
tsconfigPaths(),
svgr(),
dts(dtsConfig),
// checker(checkersConfig),
wyw({
include: [
'**/OverflowingTextWithTooltip.tsx',
'**/Chip.tsx',
'**/Tag.tsx',
'**/Avatar.tsx',
'**/AvatarChip.tsx',
],
babelOptions: {
presets: ['@babel/preset-typescript', '@babel/preset-react'],
},
}),
],
```
#### Admin panel not accessible
Run `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` in the database container to get access to the admin panel.
#### When running workflow, workflow run fails with "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
In production, logic functions are disabled by default. Set the `LOGIC_FUNCTION_TYPE` environment variable to `LOCAL` or `LAMBDA` to enable them. This can be configured via environment variables or through the admin panel database variables. See the [Logic Functions setup guide](/developers/self-host/capabilities/setup#logic-functions-available-drivers) for details.
#### When I type a message in AI chat, it doesn't answer immediately and I must refresh the page to see the answer
That means your reverse proxy is missing the configuration for stream-based API responses. Below is an example Nginx configuration:
```
location ~* ^/api/.*stream|\/graphql\/stream {
proxy_pass http://twenty_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
gzip off;
#chunked_transfer_encoding off;
add_header X-Accel-Buffering no always;
}
```
### 1-click Docker Compose
#### Unable to Log In
If you can't log in after setup:
1. Run the following commands:
```bash
docker exec -it twenty-server-1 yarn
docker exec -it twenty-server-1 npx nx database:reset --configuration=no-seed
```
2. Restart the Docker containers:
```bash
docker compose down
docker compose up -d
```
Note that the `database:reset` command will completely erase your database and recreate it from scratch.
#### Connection Issues Behind a Reverse Proxy
If you're running Twenty behind a reverse proxy and experiencing connection issues:
1. **Verify SERVER_URL:**
Ensure `SERVER_URL` in your `.env` file matches your external access URL, including `https` if SSL is enabled.
2. **Check Reverse Proxy Settings:**
- Confirm that your reverse proxy is correctly forwarding requests to the Twenty server.
- Ensure headers like `X-Forwarded-For` and `X-Forwarded-Proto` are properly set.
3. **Restart Services:**
After making changes, restart both the reverse proxy and Twenty containers.
#### Error when uploading an image - permission denied
Switching the data folder ownership on the host from root to another user and group resolves this problem.
#### 'File Not Found' error while testing Logic function in workflow
Run `yarn command:prod application:rebuild-default-deps` command in your twenty-server container to fix missing dependencies issue.
## Getting Help
If you encounter issues not covered in this guide:
- Check Logs:
View container logs for error messages:
```bash
docker compose logs
```
- Community Support:
Reach out to the [Twenty community](https://github.com/twentyhq/twenty/issues) or [support channels](https://discord.gg/cx5n4Jzs57) for assistance.

View File

@@ -0,0 +1,106 @@
---
title: Upgrade guide
icon: "arrow-up-right-dots"
---
## General guidelines
**Always back up your database before starting the upgrade process** by running:
```bash
docker exec -it {db_container_name_or_id} pg_dumpall -U {postgres_user} > databases_backup.sql
```
To restore from backup:
```bash
cat databases_backup.sql | docker exec -i {db_container_name_or_id} psql -U {postgres_user}
```
If you use Docker Compose, follow these steps:
1. Stop Twenty: `docker compose down`
2. Change the `TAG` value in the `.env` file next to your `docker-compose.yml`
3. Start Twenty: `docker compose up -d`
The server runs all required upgrade migrations automatically on startup. No manual command is needed.
## Upgrading to v2.34+ — PostgreSQL 15 minimum
Twenty v2.34 requires PostgreSQL 15 or newer. The official Docker Compose configuration uses PostgreSQL 16. If your instance connects to an externally managed database, upgrade it before starting the v2.34 server.
## Cross-version upgrades (v1.23+)
Starting from **v1.23**, Twenty supports cross-version upgrades. You can jump directly from any supported version to the latest release without stepping through each intermediate version.
For example, upgrading from v1.23 straight to v2.0 is fully supported.
## Upgrading to v2.5+ — at-rest encryption envelope
Starting in **v2.5**, Twenty stores at-rest secrets (OAuth tokens, application variables, signing-key private keys, sensitive config values, TOTP secrets) inside a versioned `enc:v2:` envelope encrypted with `ENCRYPTION_KEY` (or `APP_SECRET` if `ENCRYPTION_KEY` is unset).
The first boot on v2.5 runs slow upgrade commands that **backfill** existing rows into the new envelope. They are idempotent — interrupting and restarting the server resumes from where it left off — but they can take a while on large databases. You can monitor progress with `upgrade:status`.
You should set a dedicated `ENCRYPTION_KEY` **before** the v2.5 upgrade so the backfill writes rows under it from the start. Switching keys after the backfill requires a [rotation](/developers/self-host/capabilities/key-rotation).
## Rotating secrets and signing keys
For day-to-day operational tasks like rotating `ENCRYPTION_KEY`, rotating the JWT signing key, or revoking a leaked signing key, see the dedicated [Key rotation guide](/developers/self-host/capabilities/key-rotation).
## Checking upgrade status
The `upgrade:status` command lets you inspect the current state of your instance and workspace migrations. It is useful for debugging upgrade issues or when filing a support request.
Run it from the server container:
```bash
docker exec -it {server_container_name_or_id} yarn command:prod upgrade:status
```
Example output:
```sh
APP_VERSION: v1.23.0
Instance
Inferred version: 1.23.0
Latest command: 1.23.0_DropWorkspaceVersionColumnFastInstanceCommand_1785000000000
Status: Up to date
Executed by: v1.23.0
At: 2026-04-16T11:43:58.823Z
Workspace
Apple (20202020-1c25-4d02-bf25-6aeccf7ea419)
Inferred version: 1.23.0
Latest command: 1.23.0_UpdateGlobalObjectContextCommandMenuItemsCommand_1780000005000
Status: Up to date
Executed by: v1.23.0
At: 2026-04-16T11:44:09.361Z
Summary
Instance: Up to date
Workspaces: 1 up to date, 0 behind, 0 failed (1 total)
```
### Options
| Flag | Description |
| --- | --- |
| `-w, --workspace-id <id>` | Filter to a specific workspace. Can be passed multiple times. |
| `-f, --failed-only` | Hide up-to-date workspaces, only show behind and failed entries. |
## Troubleshooting
If the upgrade fails on some workspaces, the server will not advance past the failing step. Restarting the server (`docker compose up -d`) will retry the upgrade from where it left off.
To quickly identify problems, run:
```bash
docker exec -it {server_container_name_or_id} yarn command:prod upgrade:status --failed-only
```
This shows only workspaces that are behind or have failed, along with the error message for each failure.
## Before v1.23
If your instance is older than v1.23, you must upgrade incrementally through each major tagged version (v1.6 to v1.7, then v1.7 to v1.8, and so on) until you reach v1.23. From there, you can jump directly to the latest version.

View File

@@ -0,0 +1,25 @@
---
title: Self-Host
description: Deploy and manage Twenty on your own infrastructure.
---
<Frame>
<img src="/images/user-guide/what-is-twenty/20.png" alt="AI" />
</Frame>
## Overview
Twenty can be self-hosted on your own infrastructure, giving you full control over your data and deployment.
## Why Self-Host?
- **Data ownership**: Keep all CRM data on your own servers
- **Compliance**: Meet regulatory requirements for data residency
- **Customization**: Full access to modify and extend the platform
## Getting Started
<CardGroup cols={2}>
<Card title="Docker Compose" icon="docker" href="/developers/self-host/capabilities/docker-compose">
Quick setup with Docker
</Card>
</CardGroup>