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,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>