diff --git a/.github/workflows/ci-create-app-e2e.yaml b/.github/workflows/ci-create-app-e2e.yaml index 88877a27f1..cf779ce3f9 100644 --- a/.github/workflows/ci-create-app-e2e.yaml +++ b/.github/workflows/ci-create-app-e2e.yaml @@ -156,7 +156,7 @@ jobs: SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik' run: | cd /tmp/e2e-test-workspace/test-app - npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000 + npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000 - name: Deploy scaffolded app run: | diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml index 8503312b24..d2575f6136 100644 --- a/.github/workflows/ci-test-docker-compose.yaml +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -121,7 +121,7 @@ jobs: - name: Start container run: | docker run -d --name twenty-app-dev \ - -p 3000:3000 \ + -p 2020:2020 \ twenty-app-dev-ci docker logs twenty-app-dev -f & - name: Wait for server health @@ -129,10 +129,10 @@ jobs: echo "Waiting for twenty-app-dev to become healthy..." count=0 while true; do - status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/healthz 2>/dev/null || echo "000") + status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:2020/healthz 2>/dev/null || echo "000") if [ "$status" = "200" ]; then echo "Server is healthy!" - curl -s http://localhost:3000/healthz + curl -s http://localhost:2020/healthz break fi diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index e0b063da46..47142a8b73 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -12,164 +12,49 @@ -Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a ready‑to‑run project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk). - -- Zero‑config project bootstrap -- Preconfigured scripts for auth, dev mode (watch & sync), uninstall, and function management -- Strong TypeScript support and typed client generation - -## Documentation - -See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps - -## Prerequisites - -- Node.js 24+ (recommended) and Yarn 4 -- Docker (for the local Twenty dev server) +The official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). Sets up a ready-to-run project with [twenty-sdk](https://www.npmjs.com/package/twenty-sdk). ## Quick start ```bash -# Scaffold a new app — the CLI will offer to start a local Twenty server npx create-twenty-app@latest my-twenty-app cd my-twenty-app - -# The scaffolder can automatically: -# 1. Start a local Twenty server (Docker) -# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev) -# 3. Authenticate your app via OAuth - -# Or do it manually: -yarn twenty server start # Start local Twenty server -yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth - -# Start dev mode: watches, builds, and syncs local changes to your workspace -# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built — both available via `twenty-client-sdk`) yarn twenty dev - -# Watch your application's function logs -yarn twenty logs - -# Execute a function with a JSON payload -yarn twenty exec -n my-function -p '{"key": "value"}' - -# Execute the pre-install function -yarn twenty exec --preInstall - -# Execute the post-install function -yarn twenty exec --postInstall - -# Build the app for distribution -yarn twenty build - -# Publish the app to npm or directly to a Twenty server -yarn twenty publish - -# Uninstall the application from the current workspace -yarn twenty uninstall ``` +The scaffolder will: + +1. Create a new project with TypeScript, linting, and a preconfigured `twenty` CLI +2. Optionally start a local Twenty server (Docker) +3. Open the browser for OAuth authentication +4. Scaffold example entities and an integration test + ## Scaffolding modes -Control which example files are included when creating a new app: +| Flag | Behavior | +| -------------- | -------------------------------------------------------------------------------------------------------------- | +| `--minimal` | **(default)** Creates only core files (`application-config.ts`, `default-role.ts`, pre/post-install functions) | +| `--exhaustive` | Creates all example entities | -| Flag | Behavior | -| ------------------ | ----------------------------------------------------------------------- | -| `-e, --exhaustive` | **(default)** Creates all example files | -| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) | +Other flags: -```bash -# Default: all examples included -npx create-twenty-app@latest my-app +- `--name ` — set the app name (skips the prompt) +- `--display-name ` — set the display name (skips the prompt) +- `--description ` — set the description (skips the prompt) +- `--skip-local-instance` — skip the local server setup prompt -# Minimal: only core files -npx create-twenty-app@latest my-app -m -``` +## Documentation -## What gets scaffolded +Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started)**: -**Core files (always created):** - -- `application-config.ts` — Application metadata configuration -- `roles/default-role.ts` — Default role for logic functions -- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation) -- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation) -- TypeScript configuration, Oxlint, package.json, .gitignore -- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk - -**Example files (controlled by scaffolding mode):** - -- `objects/example-object.ts` — Example custom object with a text field -- `fields/example-field.ts` — Example standalone field extending the example object -- `logic-functions/hello-world.ts` — Example logic function with HTTP trigger -- `front-components/hello-world.tsx` — Example front component -- `views/example-view.ts` — Example saved view for the example object -- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link -- `skills/example-skill.ts` — Example AI agent skill definition -- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file) - -## Local server - -The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000). You can also manage it manually: - -```bash -yarn twenty server start # Start (pulls image if needed) -yarn twenty server status # Check if it's healthy -yarn twenty server logs # Stream logs -yarn twenty server stop # Stop (data is preserved) -yarn twenty server reset # Wipe all data and start fresh -``` - -The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`). - -## Next steps - -- Run `yarn twenty help` to see all available commands. -- Use `yarn twenty remote add ` to authenticate with your Twenty workspace via OAuth. -- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills). -- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. -- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`. - -## Build and publish your application - -Once your app is ready, build and publish it using the CLI: - -```bash -# Build the app (output goes to .twenty/output/) -yarn twenty build - -# Build and create a tarball (.tgz) for distribution -yarn twenty build --tarball - -# Publish to npm (requires npm login) -yarn twenty publish - -# Publish with a dist-tag (e.g. beta, next) -yarn twenty publish --tag beta - -# Deploy directly to a Twenty server (builds, uploads, and installs in one step) -yarn twenty deploy -``` - -### Publish to the Twenty marketplace - -You can also contribute your application to the curated marketplace: - -```bash -git clone https://github.com/twentyhq/twenty.git -cd twenty -git checkout -b feature/my-awesome-app -``` - -- Copy your app folder into `twenty/packages/twenty-apps`. -- Commit your changes and open a pull request on https://github.com/twentyhq/twenty - -Our team reviews contributions for quality, security, and reusability before merging. +- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started) — step-by-step setup, project structure, server management, CI +- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing +- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace ## Troubleshooting - Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`. -- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add `. +- Auth not working: make sure you are logged in to Twenty in the browser, then run `yarn twenty remote add`. - Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client. ## Contributing diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index c845329fe4..5c94b49114 100644 --- a/packages/create-twenty-app/package.json +++ b/packages/create-twenty-app/package.json @@ -1,6 +1,6 @@ { "name": "create-twenty-app", - "version": "0.8.0-canary.7", + "version": "0.8.0-canary.8", "description": "Command-line interface to create Twenty application", "main": "dist/cli.cjs", "bin": "dist/cli.cjs", diff --git a/packages/create-twenty-app/src/cli.ts b/packages/create-twenty-app/src/cli.ts index 9737149cea..8913c6a1de 100644 --- a/packages/create-twenty-app/src/cli.ts +++ b/packages/create-twenty-app/src/cli.ts @@ -13,10 +13,10 @@ const program = new Command(packageJson.name) 'Output the current version of create-twenty-app.', ) .argument('[directory]') - .option('-e, --exhaustive', 'Create all example entities (default)') + .option('-e, --exhaustive', 'Create all example entities') .option( '-m, --minimal', - 'Create only core entities (application-config and default-role)', + 'Create only core entities (application-config and default-role) (default)', ) .option('-n, --name ', 'Application name (skips prompt)') .option( @@ -69,7 +69,9 @@ const program = new Command(packageJson.name) process.exit(1); } - const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive'; + const mode: ScaffoldingMode = options?.exhaustive + ? 'exhaustive' + : 'minimal'; await new CreateAppCommand().execute({ directory, diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 5f46301c31..0c5d1be3ed 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -39,7 +39,7 @@ export class CreateAppCommand { try { const exampleOptions = this.resolveExampleOptions( - options.mode ?? 'exhaustive', + options.mode ?? 'minimal', ); await this.validateDirectory(appDirectory); @@ -163,7 +163,7 @@ export class CreateAppCommand { includeExampleNavigationMenuItem: false, includeExampleSkill: false, includeExampleAgent: false, - includeExampleIntegrationTest: false, + includeExampleIntegrationTest: true, }; } diff --git a/packages/twenty-apps/fixtures/function-execute-app/.oxlintrc.json b/packages/twenty-apps/fixtures/function-execute-app/.oxlintrc.json new file mode 100644 index 0000000000..87c62c5183 --- /dev/null +++ b/packages/twenty-apps/fixtures/function-execute-app/.oxlintrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "no-unused-vars": "off", + + "typescript/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/no-explicit-any": "off" + } +} diff --git a/packages/twenty-apps/fixtures/invalid-app/.oxlintrc.json b/packages/twenty-apps/fixtures/invalid-app/.oxlintrc.json new file mode 100644 index 0000000000..87c62c5183 --- /dev/null +++ b/packages/twenty-apps/fixtures/invalid-app/.oxlintrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "no-unused-vars": "off", + + "typescript/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/no-explicit-any": "off" + } +} diff --git a/packages/twenty-apps/fixtures/invalid-app/package.json b/packages/twenty-apps/fixtures/invalid-app/package.json index d1864a7f0e..485dbe2208 100644 --- a/packages/twenty-apps/fixtures/invalid-app/package.json +++ b/packages/twenty-apps/fixtures/invalid-app/package.json @@ -9,17 +9,7 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "remote:add": "twenty remote add --local", - "remote:status": "twenty remote status", - "remote:switch": "twenty remote switch", - "remote:list": "twenty remote list", - "remote:remove": "twenty remote remove", - "dev": "twenty dev", - "add": "twenty add", - "logs": "twenty logs", - "exec": "twenty exec", - "uninstall": "twenty uninstall", - "help": "twenty help", + "twenty": "twenty", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json ." }, diff --git a/packages/twenty-apps/fixtures/minimal-app/.oxlintrc.json b/packages/twenty-apps/fixtures/minimal-app/.oxlintrc.json new file mode 100644 index 0000000000..87c62c5183 --- /dev/null +++ b/packages/twenty-apps/fixtures/minimal-app/.oxlintrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "no-unused-vars": "off", + + "typescript/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/no-explicit-any": "off" + } +} diff --git a/packages/twenty-apps/fixtures/minimal-app/package.json b/packages/twenty-apps/fixtures/minimal-app/package.json index 14b4b0ddfc..7f6c16b771 100644 --- a/packages/twenty-apps/fixtures/minimal-app/package.json +++ b/packages/twenty-apps/fixtures/minimal-app/package.json @@ -9,17 +9,7 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "remote:add": "twenty remote add --local", - "remote:status": "twenty remote status", - "remote:switch": "twenty remote switch", - "remote:list": "twenty remote list", - "remote:remove": "twenty remote remove", - "dev": "twenty dev", - "add": "twenty add", - "logs": "twenty logs", - "exec": "twenty exec", - "uninstall": "twenty uninstall", - "help": "twenty help", + "twenty": "twenty", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json ." }, diff --git a/packages/twenty-apps/fixtures/postcard-app/.oxlintrc.json b/packages/twenty-apps/fixtures/postcard-app/.oxlintrc.json new file mode 100644 index 0000000000..87c62c5183 --- /dev/null +++ b/packages/twenty-apps/fixtures/postcard-app/.oxlintrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "no-unused-vars": "off", + + "typescript/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/no-explicit-any": "off" + } +} diff --git a/packages/twenty-apps/fixtures/postcard-app/package.json b/packages/twenty-apps/fixtures/postcard-app/package.json index bf09dab12b..c91688dd60 100644 --- a/packages/twenty-apps/fixtures/postcard-app/package.json +++ b/packages/twenty-apps/fixtures/postcard-app/package.json @@ -9,7 +9,9 @@ }, "packageManager": "yarn@4.9.2", "scripts": { - "twenty": "twenty" + "twenty": "twenty", + "lint": "oxlint -c .oxlintrc.json .", + "lint:fix": "oxlint --fix -c .oxlintrc.json ." }, "dependencies": { "twenty-sdk": "latest", diff --git a/packages/twenty-apps/hello-world/README.md b/packages/twenty-apps/hello-world/README.md index c96bb8324e..4901275bfa 100644 --- a/packages/twenty-apps/hello-world/README.md +++ b/packages/twenty-apps/hello-world/README.md @@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c First, authenticate to your workspace: ```bash -yarn twenty remote add http://localhost:2020 --as local +yarn twenty remote add --api-url http://localhost:2020 --as local ``` Then, start development mode to sync your app and watch for changes: @@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands: ```bash # Remotes & Authentication -yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty +yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty yarn twenty remote status # Check auth status yarn twenty remote switch # Switch default remote yarn twenty remote list # List all configured remotes diff --git a/packages/twenty-apps/internal/call-recording/README.md b/packages/twenty-apps/internal/call-recording/README.md index 19e6b99201..47a33a8914 100644 --- a/packages/twenty-apps/internal/call-recording/README.md +++ b/packages/twenty-apps/internal/call-recording/README.md @@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c First, authenticate to your workspace: ```bash -yarn twenty remote add http://localhost:2020 --as local +yarn twenty remote add --api-url http://localhost:2020 --as local ``` Then, start development mode to sync your app and watch for changes: @@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands: ```bash # Remotes & Authentication -yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty +yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty yarn twenty remote status # Check auth status yarn twenty remote switch # Switch default remote yarn twenty remote list # List all configured remotes diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json index 65f5c51c4c..4c6dfac159 100644 --- a/packages/twenty-client-sdk/package.json +++ b/packages/twenty-client-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-client-sdk", - "version": "0.8.0-canary.7", + "version": "0.8.0-canary.8", "sideEffects": false, "license": "AGPL-3.0", "scripts": { diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/dependencies.d/twenty-worker b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/dependencies.d/twenty-worker new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/type b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/type new file mode 100644 index 0000000000..bdd22a1850 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/type @@ -0,0 +1 @@ +oneshot diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/up b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/up new file mode 100644 index 0000000000..893e512e83 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/register-crons/up @@ -0,0 +1 @@ +/bin/sh /etc/s6-overlay/scripts/register-crons.sh diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/register-crons b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/register-crons new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh index 2f3e5174f8..50ad6d2538 100755 --- a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/init-db.sh @@ -1,10 +1,13 @@ #!/bin/sh set -e +step_start() { echo "==> START $1"; } +step_done() { echo "==> DONE"; } + # Wait for PostgreSQL to be ready (timeout after 60s) -echo "Waiting for PostgreSQL..." +step_start "Waiting for PostgreSQL" TRIES=0 -until su-exec postgres pg_isready -h localhost; do +until su-exec postgres pg_isready -h localhost > /dev/null 2>&1; do TRIES=$((TRIES + 1)) if [ "$TRIES" -ge 120 ]; then echo "ERROR: PostgreSQL did not become ready within 60s" @@ -12,7 +15,7 @@ until su-exec postgres pg_isready -h localhost; do fi sleep 0.5 done -echo "PostgreSQL is ready." +step_done # Create role if it doesn't exist su-exec postgres psql -h localhost -tc \ @@ -31,26 +34,36 @@ has_schema=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \ "SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'core')") if [ "$has_schema" = "f" ]; then - echo "Database appears to be empty, running initial setup..." + step_start "Running initial database setup" NODE_OPTIONS="--max-old-space-size=1500" node ./dist/database/scripts/setup-db.js + step_done fi -# Always run migrations (idempotent — skips already-applied ones) +step_start "Running migrations" yarn database:migrate:prod --force +step_done +step_start "Flushing cache" yarn command:prod cache:flush +step_done + +step_start "Running upgrade" yarn command:prod upgrade +step_done + +step_start "Flushing cache" yarn command:prod cache:flush +step_done # Only seed on first boot — check if the dev workspace already exists has_workspace=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \ "SELECT EXISTS (SELECT 1 FROM core.workspace WHERE id = '20202020-1c25-4d02-bf25-6aeccf7ea419')") if [ "$has_workspace" = "f" ]; then - echo "Seeding app dev data..." + step_start "Seeding workspace data" yarn command:prod workspace:seed:dev --light || true -else - echo "Dev workspace already seeded, skipping." + step_done fi -echo "Database initialization complete." +echo "==> START Database ready" +echo "==> DONE" diff --git a/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/register-crons.sh b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/register-crons.sh new file mode 100644 index 0000000000..89975dede5 --- /dev/null +++ b/packages/twenty-docker/twenty-app-dev/rootfs/etc/s6-overlay/scripts/register-crons.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +echo "==> START Registering cron jobs" + +cd /app/packages/twenty-server +yarn command:prod cron:register:all --dev-mode + +echo "==> DONE" diff --git a/packages/twenty-docker/twenty/Dockerfile b/packages/twenty-docker/twenty/Dockerfile index c7f0b382e0..c1a59904fe 100644 --- a/packages/twenty-docker/twenty/Dockerfile +++ b/packages/twenty-docker/twenty/Dockerfile @@ -195,9 +195,9 @@ RUN find /app/packages/twenty-server/dist -name '*.js.map' -delete # s6 service definitions COPY packages/twenty-docker/twenty-app-dev/rootfs/ / -RUN mkdir -p /data/postgres /data/redis /app/.local-storage \ +RUN mkdir -p /data/postgres /data/redis /app/packages/twenty-server/.local-storage \ && chown -R postgres:postgres /data/postgres \ - && chown 1000:1000 /data/redis /app/.local-storage + && chown 1000:1000 /data/redis /app/packages/twenty-server/.local-storage ARG REACT_APP_SERVER_BASE_URL ARG APP_VERSION=0.0.0 @@ -211,14 +211,14 @@ ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \ REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \ APP_VERSION=$APP_VERSION \ NODE_ENV=development \ - NODE_PORT=3000 \ + NODE_PORT=2020 \ DISABLE_DB_MIGRATIONS=true \ DISABLE_CRON_JOBS_REGISTRATION=true \ IS_BILLING_ENABLED=false \ SIGN_IN_PREFILLED=true -EXPOSE 3000 -VOLUME ["/data/postgres", "/app/.local-storage"] +EXPOSE 2020 +VOLUME ["/data/postgres", "/app/packages/twenty-server/.local-storage"] LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty LABEL org.opencontainers.image.description="All-in-one Twenty image for local development and SDK usage. Includes PostgreSQL, Redis, server, and worker." diff --git a/packages/twenty-docs/developers/extend/apps/building.mdx b/packages/twenty-docs/developers/extend/apps/building.mdx index 37bfa854cb..4fe2c22066 100644 --- a/packages/twenty-docs/developers/extend/apps/building.mdx +++ b/packages/twenty-docs/developers/extend/apps/building.mdx @@ -4,41 +4,174 @@ description: Define objects, logic functions, front components, and more with th --- -Apps are currently in alpha testing. The feature is functional but still evolving. + Apps are currently in alpha. The feature works but is still evolving. -## Use the SDK resources (types & config) +The `twenty-sdk` package provides typed building blocks to create your app. This page covers every entity type and API client available in the SDK. -The twenty-sdk provides typed building blocks and helper functions you use inside your app. Below are the key pieces you'll touch most often. +## DefineEntity functions -### Helper functions +The SDK provides functions to define your app entities. You must use `export default defineEntity({...})` for the SDK to detect your entities. These functions validate your configuration at build time and provide IDE autocompletion and type safety. -The SDK provides helper functions for defining your app entities. As described in [Entity detection](/developers/extend/apps/getting-started#entity-detection), you must use `export default define({...})` for your entities to be detected: + + **File organization is up to you.** + Entity detection is AST-based — the SDK finds `export default defineEntity(...)` calls regardless of where the file lives. Grouping files by type (e.g., `logic-functions/`, `roles/`) is just a convention, not a requirement. + -| Function | Purpose | -|----------|---------| -| `defineApplication` | Configure application metadata (required, one per app) | -| `defineObject` | Define custom objects with fields | -| `defineField` | Extend existing objects with additional fields or define standalone relation fields | -| `defineLogicFunction` | Define logic functions with handlers | -| `definePreInstallLogicFunction` | Define a pre-install logic function (one per app) | -| `definePostInstallLogicFunction` | Define a post-install logic function (one per app) | -| `defineFrontComponent` | Define front components for custom UI | -| `defineRole` | Configure role permissions and object access | -| `defineView` | Define saved views for objects | -| `defineNavigationMenuItem` | Define sidebar navigation links | -| `defineSkill` | Define AI agent skills | -| `defineAgent` | Define AI agents | -| `definePageLayout` | Define custom page layouts | + + -These functions validate your configuration at build time and provide IDE autocompletion and type safety. +Roles encapsulate permissions on your workspace's objects and actions. -### Defining objects +```ts restricted-company-role.ts +import { + defineRole, + PermissionFlag, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk'; + +export default defineRole({ + universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6', + label: 'My new role', + description: 'A role that can be used in your workspace', + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canBeAssignedToAgents: false, + canBeAssignedToUsers: false, + canBeAssignedToApiKeys: false, + objectPermissions: [ + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + fieldPermissions: [ + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + permissionFlags: [PermissionFlag.APPLICATIONS], +}); +``` + + + + +Every app must have exactly one `defineApplication` call that describes: + +- **Identity**: identifiers, display name, and description. +- **Permissions**: which role its functions and front components use. +- **(Optional) Variables**: key–value pairs exposed to your functions as environment variables. +- **(Optional) Pre-install / post-install functions**: logic functions that run before or after installation. + +```ts src/application-config.ts +import { defineApplication } from 'twenty-sdk'; +import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'; + +export default defineApplication({ + universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', + displayName: 'My Twenty App', + description: 'My first Twenty app', + icon: 'IconWorld', + applicationVariables: { + DEFAULT_RECIPIENT_NAME: { + universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de', + description: 'Default recipient name for postcards', + value: 'Jane Doe', + isSecret: false, + }, + }, + defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, +}); +``` + +Notes: +- `universalIdentifier` fields are deterministic IDs you own. Generate them once and keep them stable across syncs. +- `applicationVariables` become environment variables for your functions and front components (e.g., `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`). +- `defaultRoleUniversalIdentifier` must reference a role defined with `defineRole()` (see above). +- Pre-install and post-install functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`. + +#### Marketplace metadata + +If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how it appears in the marketplace: + +| Field | Description | +|-------|-------------| +| `author` | Author or company name | +| `category` | App category for marketplace filtering | +| `logoUrl` | Path to your app logo (e.g., `public/logo.png`) | +| `screenshots` | Array of screenshot paths (e.g., `public/screenshot-1.png`) | +| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm | +| `websiteUrl` | Link to your website | +| `termsUrl` | Link to terms of service | +| `emailSupport` | Support email address | +| `issueReportUrl` | Link to issue tracker | + +#### Roles and permissions + +The `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions and front components. See `defineRole` above for details. + +- The runtime token injected as `TWENTY_APP_ACCESS_TOKEN` is derived from this role. +- The typed client is restricted to the permissions granted to that role. +- Follow least-privilege: create a dedicated role with only the permissions your functions need. + +##### Default function role + +When you scaffold a new app, the CLI creates a default role file: + +```ts src/roles/default-role.ts +import { defineRole, PermissionFlag } from 'twenty-sdk'; + +export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = + 'b648f87b-1d26-4961-b974-0908fd991061'; + +export default defineRole({ + universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + label: 'Default function role', + description: 'Default role for function Twenty client', + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canBeAssignedToAgents: false, + canBeAssignedToUsers: false, + canBeAssignedToApiKeys: false, + objectPermissions: [], + fieldPermissions: [], + permissionFlags: [], +}); +``` + +This role's `universalIdentifier` is referenced in `application-config.ts` as `defaultRoleUniversalIdentifier`: + +- **\*.role.ts** defines what the role can do. +- **application-config.ts** points to that role so your functions inherit its permissions. + +Notes: +- Start from the scaffolded role, then progressively restrict it following least-privilege. +- Replace `objectPermissions` and `fieldPermissions` with the objects and fields your functions actually need. +- `permissionFlags` control access to platform-level capabilities. Keep them minimal. +- See a working example: [`hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts). + + + Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation: -```typescript -// src/objects/postCard.object.ts +```ts postCard.object.ts import { defineObject, FieldType } from 'twenty-sdk'; enum PostCardStatus { @@ -122,12 +255,12 @@ Key points: but this is not recommended. -### Defining fields on existing objects + + Use `defineField()` to add fields to objects you don't own — such as standard Twenty objects (Person, Company, etc.) or objects from other apps. Unlike inline fields in `defineObject()`, standalone fields require an `objectUniversalIdentifier` to specify which object they extend: -```typescript -// src/fields/company-loyalty-tier.field.ts +```ts src/fields/company-loyalty-tier.field.ts import { defineField, FieldType } from 'twenty-sdk'; export default defineField({ @@ -150,7 +283,8 @@ Key points: - When defining fields inline in `defineObject()`, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object. - `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`. -### Relations + + Relations connect objects together. In Twenty, relations are always **bidirectional** — you define both sides, and each side references the other. @@ -176,8 +310,7 @@ Suppose a `PostCard` can be sent to many `PostCardRecipient` records. Each recip **Step 1: Define the ONE_TO_MANY side on PostCard** (the "one" side): -```typescript -// src/fields/post-card-recipients-on-post-card.field.ts +```ts src/fields/post-card-recipients-on-post-card.field.ts import { defineField, FieldType, RelationType } from 'twenty-sdk'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object'; @@ -204,8 +337,7 @@ export default defineField({ **Step 2: Define the MANY_TO_ONE side on PostCardRecipient** (the "many" side — holds the foreign key): -```typescript -// src/fields/post-card-on-post-card-recipient.field.ts +```ts src/fields/post-card-on-post-card-recipient.field.ts import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object'; @@ -240,8 +372,7 @@ export default defineField({ To create a relation with a built-in Twenty object (Person, Company, etc.), use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`: -```typescript -// src/fields/person-on-self-hosting-user.field.ts +```ts src/fields/person-on-self-hosting-user.field.ts import { defineField, FieldType, @@ -288,7 +419,7 @@ export default defineField({ You can also define relation fields directly inside `defineObject()`. In that case, omit `objectUniversalIdentifier` — it's inherited from the parent object: -```typescript +```ts export default defineObject({ universalIdentifier: '...', nameSingular: 'postCardRecipient', @@ -311,135 +442,15 @@ export default defineObject({ ], }); ``` - -### Application config (application-config.ts) - -Every app has a single `application-config.ts` file that describes: - -- **Who the app is**: identifiers, display name, and description. -- **How its functions run**: which role they use for permissions. -- **(Optional) variables**: key–value pairs exposed to your functions as environment variables. -- **(Optional) pre-install function**: a logic function that runs before the app is installed. -- **(Optional) post-install function**: a logic function that runs after the app is installed. - -Use `defineApplication()` to define your application configuration: - -```typescript -// src/application-config.ts -import { defineApplication } from 'twenty-sdk'; -import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'; - -export default defineApplication({ - universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', - displayName: 'My Twenty App', - description: 'My first Twenty app', - icon: 'IconWorld', - applicationVariables: { - DEFAULT_RECIPIENT_NAME: { - universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de', - description: 'Default recipient name for postcards', - value: 'Jane Doe', - isSecret: false, - }, - }, - defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, -}); -``` - -Notes: -- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs. -- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`). -- `defaultRoleUniversalIdentifier` must match the role file (see below). -- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions). - -#### Marketplace metadata - -If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how your app appears in the marketplace: - -| Field | Description | -|-------|-------------| -| `author` | Author or company name | -| `category` | App category for marketplace filtering | -| `logoUrl` | Path to your app logo (relative to `./assets/`) | -| `screenshots` | Array of screenshot paths (relative to `./assets/`) | -| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm | -| `websiteUrl` | Link to your website | -| `termsUrl` | Link to terms of service | -| `emailSupport` | Support email address | -| `issueReportUrl` | Link to issue tracker | - -#### Roles and permissions - -Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions. - -- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role. -- The typed client will be restricted to the permissions granted to that role. -- Follow least-privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier. - -##### Default function role (*.role.ts) - -When you scaffold a new app, the CLI also creates a default role file. Use `defineRole()` to define roles with built-in validation: - -```typescript -// src/roles/default-role.ts -import { defineRole, PermissionFlag } from 'twenty-sdk'; - -export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = - 'b648f87b-1d26-4961-b974-0908fd991061'; - -export default defineRole({ - universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, - label: 'Default function role', - description: 'Default role for function Twenty client', - canReadAllObjectRecords: false, - canUpdateAllObjectRecords: false, - canSoftDeleteAllObjectRecords: false, - canDestroyAllObjectRecords: false, - canUpdateAllSettings: false, - canBeAssignedToAgents: false, - canBeAssignedToUsers: false, - canBeAssignedToApiKeys: false, - objectPermissions: [ - { - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: false, - canDestroyObjectRecords: false, - }, - ], - fieldPermissions: [ - { - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', - fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff', - canReadFieldValue: false, - canUpdateFieldValue: false, - }, - ], - permissionFlags: [PermissionFlag.APPLICATIONS], -}); -``` - -The `universalIdentifier` of this role is then referenced in `application-config.ts` as `defaultRoleUniversalIdentifier`. In other words: - -- **\*.role.ts** defines what the default function role can do. -- **application-config.ts** points to that role so your functions inherit its permissions. - -Notes: -- Start from the scaffolded role, then progressively restrict it following least-privilege. -- Replace the `objectPermissions` and `fieldPermissions` with the objects/fields your functions need. -- `permissionFlags` control access to platform-level capabilities. Keep them minimal; add only what you need. -- See a working example in the Hello World app: [`packages/twenty-apps/hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts). - -### Logic function config and entrypoint + + Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers. -```typescript -// src/logic-functions/createPostCard.logic-function.ts +```ts src/logic-functions/createPostCard.logic-function.ts import { defineLogicFunction } from 'twenty-sdk'; import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk'; -import { CoreApiClient, type Person } from 'twenty-sdk/generated'; +import { CoreApiClient, type Person } from 'twenty-client-sdk/core'; const handler = async (params: RoutePayload) => { const client = new CoreApiClient(); @@ -462,151 +473,56 @@ export default defineLogicFunction({ name: 'create-new-post-card', timeoutSeconds: 2, handler, - triggers: [ - // Public HTTP route trigger '/s/post-card/create' - { - universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', - type: 'route', - path: '/post-card/create', - httpMethod: 'GET', - isAuthRequired: false, - }, - // Cron trigger (CRON pattern) - // { - // universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', - // type: 'cron', - // pattern: '0 0 1 1 *', - // }, - // Database event trigger - // { - // universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', - // type: 'databaseEvent', - // eventName: 'person.updated', - // updatedFields: ['name'], - // }, - ], + httpRouteTriggerSettings: { + path: '/post-card/create', + httpMethod: 'GET', + isAuthRequired: false, + }, + /*databaseEventTriggerSettings: { + eventName: 'people.created', + },*/ + /*cronTriggerSettings: { + pattern: '0 0 1 1 *', + },*/ }); ``` -Common trigger types: -- **route**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**: -> e.g. `path: '/post-card/create',` -> call on `/s/post-card/create` +Available trigger types: +- **httpRoute**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**: +> e.g. `path: '/post-card/create'` is callable at `https://your-twenty-server.com/s/post-card/create` - **cron**: Runs your function on a schedule using a CRON expression. - **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function. -> e.g. `person.updated` +> e.g. `person.updated`, `*.created`, `company.*` -Notes: -- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions. -- You can mix multiple trigger types in a single function. - -### Pre-install functions - -A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds. - -When you scaffold a new app with `create-twenty-app`, a pre-install function is generated for you at `src/logic-functions/pre-install.ts`: - -```typescript -// src/logic-functions/pre-install.ts -import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; - -const handler = async (payload: InstallLogicFunctionPayload): Promise => { - console.log('Pre install logic function executed successfully!', payload.previousVersion); -}; - -export default definePreInstallLogicFunction({ - universalIdentifier: '', - name: 'pre-install', - description: 'Runs before installation to prepare the application.', - timeoutSeconds: 300, - handler, -}); -``` - -You can also manually execute the pre-install function at any time using the CLI: + +You can also manually execute a function using the CLI: ```bash filename="Terminal" -yarn twenty exec --preInstall +yarn twenty exec -n create-new-post-card -p '{"key": "value"}' ``` -Key points: -- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). -- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). -- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected. -- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. -- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks. -- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `exec --preInstall`. - -### Post-install functions - -A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings. - -When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`: - -```typescript -// src/logic-functions/post-install.ts -import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; - -const handler = async (payload: InstallLogicFunctionPayload): Promise => { - console.log('Post install logic function executed successfully!', payload.previousVersion); -}; - -export default definePostInstallLogicFunction({ - universalIdentifier: '', - name: 'post-install', - description: 'Runs after installation to set up the application.', - timeoutSeconds: 300, - handler, -}); -``` - -You can also manually execute the post-install function at any time using the CLI: - ```bash filename="Terminal" -yarn twenty exec --postInstall +yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf ``` -Key points: -- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). -- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). -- Only one post-install function is allowed per application. The manifest build will error if more than one is detected. -- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. -- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding. -- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `exec --postInstall`. +You can watch logs with: -### Route trigger payload - - -**Breaking change (v1.16, January 2026):** The route trigger payload format has changed. Prior to v1.16, query parameters, path parameters, and body were sent directly as the payload. Starting with v1.16, they are nested inside a structured `RoutePayload` object. - -**Before v1.16:** -```typescript -const handler = async (params) => { - const { param1, param2 } = params; // Direct access -}; +```bash filename="Terminal" +yarn twenty logs ``` + -**After v1.16:** -```typescript -const handler = async (event: RoutePayload) => { - const { param1, param2 } = event.body; // Access via .body - const { queryParam } = event.queryStringParameters; - const { id } = event.pathParameters; -}; -``` +#### Route trigger payload -**To migrate existing functions:** Update your handler to destructure from `event.body`, `event.queryStringParameters`, or `event.pathParameters` instead of directly from the params object. - +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`: -When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the AWS HTTP API v2 format. Import the type from `twenty-sdk`: - -```typescript +```ts import { defineLogicFunction, type RoutePayload } from 'twenty-sdk'; const handler = async (event: RoutePayload) => { - // Access request data const { headers, queryStringParameters, pathParameters, body } = event; - - // HTTP method and path are available in requestContext const { method, path } = event.requestContext.http; return { message: 'Success' }; @@ -615,41 +531,39 @@ const handler = async (event: RoutePayload) => { The `RoutePayload` type has the following structure: -| Property | Type | Description | -|----------|------|-------------| -| `headers` | `Record` | HTTP headers (only those listed in `forwardedRequestHeaders`) | -| `queryStringParameters` | `Record` | Query string parameters (multiple values joined with commas) | -| `pathParameters` | `Record` | Path parameters extracted from the route pattern (e.g., `/users/:id` -> `{ id: '123' }`) | -| `body` | `object \| null` | Parsed request body (JSON) | -| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded | -| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) | -| `requestContext.http.path` | `string` | Raw request path | + | Property | Type | Description | Example | + |----------|------|-------------|---------| + | `headers` | `Record` | HTTP headers (only those listed in `forwardedRequestHeaders`) | see section below | + | `queryStringParameters` | `Record` | 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` | Path parameters extracted from the route pattern | `/users/:id`, `/users/123` -> `{ id: '123' }` | + | `body` | `object \| null` | Parsed request body (JSON) | `{ id: 1 }` -> `{ id: 1 }` | + | `isBase64Encoded` | `boolean` | Whether the body is base64 encoded | | + | `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) | | + | `requestContext.http.path` | `string` | Raw request path | | -### Forwarding HTTP headers -By default, HTTP headers from incoming requests are **not** passed to your logic function for security reasons. To access specific headers, explicitly list them in the `forwardedRequestHeaders` array: +#### forwardedRequestHeaders -```typescript +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, - triggers: [ - { - universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', - type: 'route', - path: '/webhook', - httpMethod: 'POST', - isAuthRequired: false, - forwardedRequestHeaders: ['x-webhook-signature', 'content-type'], - }, - ], + httpRouteTriggerSettings: { + path: '/webhook', + httpMethod: 'POST', + isAuthRequired: false, + forwardedRequestHeaders: ['x-webhook-signature', 'content-type'], + }, }); ``` -In your handler, you can then access these headers: +In your handler, access the forwarded headers like this: -```typescript +```ts const handler = async (event: RoutePayload) => { const signature = event.headers['x-webhook-signature']; const contentType = event.headers['content-type']; @@ -660,22 +574,16 @@ const handler = async (event: RoutePayload) => { ``` - Header names are normalized to lowercase. Access them using lowercase keys (for example, `event.headers['content-type']`). +Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`). -You can create new functions in two ways: +#### Exposing a function as a tool -- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new logic function. This generates a starter file with a handler and config. -- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern. +Logic functions can be exposed as **tools** for AI agents and workflows. When marked as a tool, a function becomes discoverable by Twenty's AI features and can be used in workflow automations. -### Marking a logic function as a tool +To mark a logic function as a tool, set `isTool: true`: -Logic functions can be exposed as **tools** for AI agents and workflows. When a function is marked as a tool, it becomes discoverable by Twenty's AI features and can be selected as a step in workflow automations. - -To mark a logic function as a tool, set `isTool: true` and provide a `toolInputSchema` describing the expected input parameters using [JSON Schema](https://json-schema.org/): - -```typescript -// src/logic-functions/enrich-company.logic-function.ts +```ts src/logic-functions/enrich-company.logic-function.ts import { defineLogicFunction } from 'twenty-sdk'; import { CoreApiClient } from 'twenty-client-sdk/core'; @@ -704,6 +612,17 @@ export default defineLogicFunction({ timeoutSeconds: 10, handler, isTool: true, +}); +``` + +Key points: + +- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events at the same time. +- **`toolInputSchema`** (optional): A JSON Schema object describing the parameters your function accepts. The schema is computed automatically from source code static analysis, but you can set it explicitly: + +```ts +export default defineLogicFunction({ + ..., toolInputSchema: { type: 'object', properties: { @@ -721,59 +640,364 @@ export default defineLogicFunction({ }); ``` -Key points: - -- **`isTool`** (`boolean`, default: `false`): When set to `true`, the function is registered as a tool and becomes available to AI agents and workflow automations. -- **`toolInputSchema`** (`object`, optional): A JSON Schema object that describes the parameters your function accepts. AI agents use this schema to understand what inputs the tool expects and to validate calls. If omitted, the schema defaults to `{ type: 'object', properties: {} }` (no parameters). -- Functions with `isTool: false` (or unset) are **not** exposed as tools. They can still be executed directly or called by other functions, but will not appear in tool discovery. -- **Tool naming**: When exposed as a tool, the function name is automatically normalized to `logic_function_` (lowercased, non-alphanumeric characters replaced with underscores). For example, `enrich-company` becomes `logic_function_enrich_company`. -- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events (cron, database events, routes) at the same time. - **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. -### Front components + + -Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation: +A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds. -```typescript -// src/front-components/my-widget.tsx +```ts src/logic-functions/pre-install.ts +import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; + +const handler = async (payload: InstallLogicFunctionPayload): Promise => { + console.log('Pre install logic function executed successfully!', payload.previousVersion); +}; + +export default definePreInstallLogicFunction({ + universalIdentifier: 'e0604b9e-e946-456b-886d-3f27d9a6b324', + name: 'pre-install', + description: 'Runs before installation to prepare the application.', + timeoutSeconds: 300, + handler, +}); +``` + +You can also manually execute the pre-install function at any time using the CLI: + +```bash filename="Terminal" +yarn twenty exec --preInstall +``` + +Key points: +- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). +- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). +- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected. +- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. +- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks. + + + + +A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings. + +```ts src/logic-functions/post-install.ts +import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; + +const handler = async (payload: InstallLogicFunctionPayload): Promise => { + console.log('Post install logic function executed successfully!', payload.previousVersion); +}; + +export default definePostInstallLogicFunction({ + universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210', + name: 'post-install', + description: 'Runs after installation to set up the application.', + timeoutSeconds: 300, + handler, +}); +``` + +You can also manually execute the post-install function at any time using the CLI: + +```bash filename="Terminal" +yarn twenty exec --postInstall +``` + +Key points: +- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). +- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). +- Only one post-install function is allowed per application. The manifest build will error if more than one is detected. +- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. +- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding. + + + + +Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code is sandboxed but renders natively in the page, not in an iframe. + +#### Basic example + +The quickest way to see a front component in action is to register it as a **command**. Adding a `command` field with `isPinned: true` makes it appear as a quick-action button in the top-right corner of the page — no page layout needed: + +```tsx src/front-components/hello-world.tsx import { defineFrontComponent } from 'twenty-sdk'; -const MyWidget = () => { +const HelloWorld = () => { return (
-

My Custom Widget

-

This is a custom front component for Twenty.

+

Hello from my app!

+

This component renders inside Twenty.

); }; export default defineFrontComponent({ - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - name: 'my-widget', - description: 'A custom widget component', - component: MyWidget, + universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948', + name: 'hello-world', + description: 'A simple front component', + component: HelloWorld, + command: { + universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345', + shortLabel: 'Hello', + label: 'Hello World', + icon: 'IconBolt', + isPinned: true, + availabilityType: 'GLOBAL', + }, }); ``` -Key points: -- Front components are React components that render in isolated contexts within Twenty. -- The `component` field references your React component. -- Components are built and synced automatically during `yarn twenty dev`. +After syncing with `yarn twenty dev`, the quick action appears in the top-right corner of the page: -You can create new front components in two ways: +
+ Quick action button in the top-right corner +
-- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new front component. -- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern. +Click it to render the component inline. -### Skills +{/* TODO: add screenshot of the rendered front component */} + +#### Configuration fields + +| Field | Required | Description | +|-------|----------|-------------| +| `universalIdentifier` | Yes | Stable unique ID for this component | +| `component` | Yes | A React component function | +| `name` | No | Display name | +| `description` | No | Description of what the component does | +| `isHeadless` | No | Set to `true` if the component has no visible UI (see below) | +| `command` | No | Register the component as a command (see [command options](#command-options) below) | + +#### Placing a front component on a page + +Beyond commands, you can embed a front component directly into a record page by adding it as a widget in a **page layout**. See the [definePageLayout](#definepagelayout) section for details. + +#### Headless components (`isHeadless: true`) + +Headless components render no visible UI but still run React logic. This is useful for **effect components** — components that perform side effects when mounted, such as syncing data, starting a timer, listening to events, or triggering a notification. + +```tsx src/front-components/sync-tracker.tsx +import { defineFrontComponent, useRecordId, enqueueSnackbar } from 'twenty-sdk'; +import { useEffect } from 'react'; + +const SyncTracker = () => { + const recordId = useRecordId(); + + useEffect(() => { + enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' }); + }, [recordId]); + + return null; +}; + +export default defineFrontComponent({ + universalIdentifier: '...', + name: 'sync-tracker', + description: 'Tracks record views silently', + isHeadless: true, + component: SyncTracker, +}); +``` + +Because the component returns `null`, Twenty skips rendering a container for it — no empty space appears in the layout. The component still has access to all hooks and the host communication API. + +#### Accessing runtime context + +Inside your component, use SDK hooks to access the current user, record, and component instance: + +```tsx src/front-components/record-info.tsx +import { + defineFrontComponent, + useUserId, + useRecordId, + useFrontComponentId, +} from 'twenty-sdk'; + +const RecordInfo = () => { + const userId = useUserId(); + const recordId = useRecordId(); + const componentId = useFrontComponentId(); + + return ( +
+

User: {userId}

+

Record: {recordId ?? 'No record context'}

+

Component: {componentId}

+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012', + name: 'record-info', + component: RecordInfo, +}); +``` + +Available hooks: + +| Hook | Returns | Description | +|------|---------|-------------| +| `useUserId()` | `string` or `null` | The current user's ID | +| `useRecordId()` | `string` or `null` | The current record's ID (when placed on a record page) | +| `useFrontComponentId()` | `string` | This component instance's ID | +| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function | + +#### Host communication API + +Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`: + +| Function | Description | +|----------|-------------| +| `navigate(to, params?, queryParams?, options?)` | Navigate to a page in the app | +| `openSidePanelPage(params)` | Open a side panel | +| `closeSidePanel()` | Close the side panel | +| `openCommandConfirmationModal(params)` | Show a confirmation dialog | +| `enqueueSnackbar(params)` | Show a toast notification | +| `unmountFrontComponent()` | Unmount the component | +| `updateProgress(progress)` | Update a progress indicator | + +#### Command options + +Adding a `command` field to `defineFrontComponent` registers the component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page. + +| Field | Required | Description | +|-------|----------|-------------| +| `universalIdentifier` | Yes | Stable unique ID for the command | +| `label` | Yes | Full label shown in the command menu (Cmd+K) | +| `shortLabel` | No | Shorter label displayed on the pinned quick-action button | +| `icon` | No | Icon name displayed next to the label (e.g. `'IconBolt'`, `'IconSend'`) | +| `isPinned` | No | When `true`, shows the command as a quick-action button in the top-right corner of the page | +| `availabilityType` | No | Controls where the command appears: `'GLOBAL'` (always available), `'RECORD_SELECTION'` (only when records are selected), or `'FALLBACK'` (shown when no other commands match) | +| `availabilityObjectUniversalIdentifier` | No | Restrict the command to pages of a specific object type (e.g. only on Company records) | +| `conditionalAvailabilityExpression` | No | A boolean expression to dynamically control whether the command is visible (see below) | + +#### Conditional availability expressions + +The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions: + +```tsx +import { + defineFrontComponent, + pageType, + numberOfSelectedRecords, + objectPermissions, + everyEquals, + isDefined, +} from 'twenty-sdk'; + +export default defineFrontComponent({ + universalIdentifier: '...', + name: 'bulk-action', + component: BulkAction, + command: { + universalIdentifier: '...', + label: 'Bulk Update', + availabilityType: 'RECORD_SELECTION', + conditionalAvailabilityExpression: everyEquals( + objectPermissions, + 'canUpdateObjectRecords', + true, + ), + }, +}); +``` + +**Context variables** — these represent the current state of the page: + +| Variable | Type | Description | +|----------|------|-------------| +| `pageType` | `string` | Current page type (e.g. `'RecordIndexPage'`, `'RecordShowPage'`) | +| `isInSidePanel` | `boolean` | Whether the component is rendered in a side panel | +| `numberOfSelectedRecords` | `number` | Number of currently selected records | +| `isSelectAll` | `boolean` | Whether "select all" is active | +| `selectedRecords` | `array` | The selected record objects | +| `favoriteRecordIds` | `array` | IDs of favorited records | +| `objectPermissions` | `object` | Permissions for the current object type | +| `targetObjectReadPermissions` | `object` | Read permissions for the target object | +| `targetObjectWritePermissions` | `object` | Write permissions for the target object | +| `featureFlags` | `object` | Active feature flags | +| `objectMetadataItem` | `object` | Metadata of the current object type | +| `hasAnySoftDeleteFilterOnView` | `boolean` | Whether the current view has a soft-delete filter | + +**Operators** — combine variables into boolean expressions: + +| Operator | Description | +|----------|-------------| +| `isDefined(value)` | `true` if the value is not null/undefined | +| `isNonEmptyString(value)` | `true` if the value is a non-empty string | +| `includes(array, value)` | `true` if the array contains the value | +| `includesEvery(array, prop, value)` | `true` if every item's property includes the value | +| `every(array, prop)` | `true` if the property is truthy on every item | +| `everyDefined(array, prop)` | `true` if the property is defined on every item | +| `everyEquals(array, prop, value)` | `true` if the property equals the value on every item | +| `some(array, prop)` | `true` if the property is truthy on at least one item | +| `someDefined(array, prop)` | `true` if the property is defined on at least one item | +| `someEquals(array, prop, value)` | `true` if the property equals the value on at least one item | +| `someNonEmptyString(array, prop)` | `true` if the property is a non-empty string on at least one item | +| `none(array, prop)` | `true` if the property is falsy on every item | +| `noneDefined(array, prop)` | `true` if the property is undefined on every item | +| `noneEquals(array, prop, value)` | `true` if the property does not equal the value on any item | + +#### Public assets + +Front components can access files from the app's `public/` directory using `getPublicAssetUrl`: + +```tsx +import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk'; + +const Logo = () => Logo; + +export default defineFrontComponent({ + universalIdentifier: '...', + name: 'logo', + component: Logo, +}); +``` + +See the [public assets section](#accessing-public-assets-with-getpublicasseturl) for details. + +#### Styling + +Front components support multiple styling approaches. You can use: + +- **Inline styles** — `style={{ color: 'red' }}` +- **Twenty UI components** — import from `twenty-sdk/ui` (Button, Tag, Status, Chip, Avatar, and more) +- **Emotion** — CSS-in-JS with `@emotion/react` +- **Styled-components** — `styled.div` patterns +- **Tailwind CSS** — utility classes +- **Any CSS-in-JS library** compatible with React + +```tsx +import { defineFrontComponent } from 'twenty-sdk'; +import { Button, Tag, Status } from 'twenty-sdk/ui'; + +const StyledWidget = () => { + return ( +
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456', + name: 'styled-widget', + component: StyledWidget, +}); +``` + +
+ + Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation: -```typescript -// src/skills/example-skill.ts +```ts src/skills/example-skill.ts import { defineSkill } from 'twenty-sdk'; export default defineSkill({ @@ -797,25 +1021,327 @@ Key points: - `icon` (optional) sets the icon displayed in the UI. - `description` (optional) provides additional context about the skill's purpose. -You can create new skills in two ways: + + -- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new skill. -- **Manual**: Create a new file and use `defineSkill()`, following the same pattern. +Agents are AI assistants that live inside your workspace. Use `defineAgent()` to create agents with a custom system prompt: -### Typed API clients (`twenty-client-sdk`) +```ts src/agents/example-agent.ts +import { defineAgent } from 'twenty-sdk'; -The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components: +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. + + + + +Views are saved configurations for how records of an object are displayed — including which fields are visible, their order, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app: + +```ts src/views/example-view.ts +import { defineView, ViewKey } from 'twenty-sdk'; +import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object'; +import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object'; + +export default defineView({ + universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + name: 'All example items', + objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, + icon: 'IconList', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0', + fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 200, + }, + ], +}); +``` + +Key points: +- `objectUniversalIdentifier` specifies which object this view applies to. +- `key` determines the view type (e.g., `ViewKey.INDEX` for the main list view). +- `fields` controls which columns appear and their order. Each field references a `fieldMetadataUniversalIdentifier`. +- You can also define `filters`, `filterGroups`, `groups`, and `fieldGroups` for more advanced configurations. +- `position` controls the ordering when multiple views exist for the same object. + + + + +Navigation menu items add custom entries to the workspace sidebar. Use `defineNavigationMenuItem()` to link to views, external URLs, or objects: + +```ts src/navigation-menu-items/example-navigation-menu-item.ts +import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk'; +import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view'; + +export default defineNavigationMenuItem({ + universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c', + name: 'example-navigation-menu-item', + icon: 'IconList', + color: 'blue', + position: 0, + type: NavigationMenuItemType.VIEW, + viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER, +}); +``` + +Key points: +- `type` determines what the menu item links to: `NavigationMenuItemType.VIEW` for a saved view, or `NavigationMenuItemType.LINK` for an external URL. +- For view links, set `viewUniversalIdentifier`. For external links, set `link`. +- `position` controls the ordering in the sidebar. +- `icon` and `color` (optional) customize the appearance. + + + + +Page layouts let you customize how a record detail page looks — which tabs appear, what widgets are inside each tab, and how they are arranged. Use `definePageLayout()` to ship custom layouts with your app: + +```ts src/page-layouts/example-record-page-layout.ts +import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk'; +import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object'; +import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world'; + +export default definePageLayout({ + universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134', + name: 'Example Record Page', + type: 'RECORD_PAGE', + objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, + tabs: [ + { + universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5', + title: 'Hello World', + position: 50, + icon: 'IconWorld', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d', + title: 'Hello World', + type: 'FRONT_COMPONENT', + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + }, + ], + }, + ], +}); +``` + +Key points: +- `type` is typically `'RECORD_PAGE'` to customize the detail view of a specific object. +- `objectUniversalIdentifier` specifies which object this layout applies to. +- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`CANVAS` for free-form layout). +- Each `widget` inside a tab can render a front component, a relation list, or other built-in widget types. +- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones. + + +
+ +## Public assets (`public/` folder) + +The `public/` folder at the root of your app holds static files — images, icons, fonts, or any other assets your app needs at runtime. These files are automatically included in builds, synced during dev mode, and uploaded to the server. + +Files placed in `public/` are: + +- **Publicly accessible** — once synced to the server, assets are served at a public URL. No authentication is needed to access them. +- **Available in front components** — use asset URLs to display images, icons, or any media inside your React components. +- **Available in logic functions** — reference asset URLs in emails, API responses, or any server-side logic. +- **Used for marketplace metadata** — the `logoUrl` and `screenshots` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published. +- **Auto-synced in dev mode** — when you add, update, or delete a file in `public/`, it is synced to the server automatically. No restart needed. +- **Included in builds** — `yarn twenty build` bundles all public assets into the distribution output. + +### Accessing public assets with `getPublicAssetUrl` + +Use the `getPublicAssetUrl` helper from `twenty-sdk` to get the full URL of a file in your `public/` directory. It works in both **logic functions** and **front components**. + +**In a logic function:** + +```ts src/logic-functions/send-invoice.ts +import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk'; + +const handler = async (): Promise => { + const logoUrl = getPublicAssetUrl('logo.png'); + const invoiceUrl = getPublicAssetUrl('templates/invoice.png'); + + // Fetch the file content (no auth required — public endpoint) + const response = await fetch(invoiceUrl); + const buffer = await response.arrayBuffer(); + + return { logoUrl, size: buffer.byteLength }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'a1b2c3d4-...', + name: 'send-invoice', + description: 'Sends an invoice with the app logo', + timeoutSeconds: 10, + handler, +}); +``` + +**In a front component:** + +```tsx src/front-components/company-card.tsx +import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk'; + +export default defineFrontComponent(() => { + const logoUrl = getPublicAssetUrl('logo.png'); + + return App logo; +}); +``` + +The `path` argument is relative to your app's `public/` folder. Both `getPublicAssetUrl('logo.png')` and `getPublicAssetUrl('public/logo.png')` resolve to the same URL — the `public/` prefix is stripped automatically if present. + +## Using npm packages + +You can install and use any npm package in your app. Both logic functions and front components are bundled with [esbuild](https://esbuild.github.io/), which inlines all dependencies into the output — no `node_modules` are needed at runtime. + +### Installing a package + +```bash filename="Terminal" +yarn add axios +``` + +Then import it in your code: + +```ts src/logic-functions/fetch-data.ts +import { defineLogicFunction } from 'twenty-sdk'; +import axios from 'axios'; + +const handler = async (): Promise => { + const { data } = await axios.get('https://api.example.com/data'); + + return { data }; +}; + +export default defineLogicFunction({ + universalIdentifier: '...', + name: 'fetch-data', + description: 'Fetches data from an external API', + timeoutSeconds: 10, + handler, +}); +``` + +The same works for front components: + +```tsx src/front-components/chart.tsx +import { defineFrontComponent } from 'twenty-sdk'; +import { format } from 'date-fns'; + +const DateWidget = () => { + return

Today is {format(new Date(), 'MMMM do, yyyy')}

; +}; + +export default defineFrontComponent({ + universalIdentifier: '...', + name: 'date-widget', + component: DateWidget, +}); +``` + +### How bundling works + +The build step (`yarn twenty dev` or `yarn twenty build`) uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle. + +**Logic functions** run in a Node.js environment. Node built-in modules (`fs`, `path`, `crypto`, `http`, etc.) are available and do not need to be installed. + +**Front components** run in a Web Worker. Node built-in modules are **not** available — only browser APIs and npm packages that work in a browser environment. + +Both environments have `twenty-client-sdk/core` and `twenty-client-sdk/metadata` available as pre-provided modules — these are not bundled but resolved at runtime by the server. + +## Scaffolding entities with `yarn twenty add` + +Instead of creating entity files by hand, you can use the interactive scaffolder: + +```bash filename="Terminal" +yarn twenty add +``` + +This prompts you to pick an entity type and walks you through the required fields. It generates a ready-to-use file with a stable `universalIdentifier` and the correct `defineEntity()` call. + +You can also pass the entity type directly to skip the first prompt: + +```bash filename="Terminal" +yarn twenty add object +yarn twenty add logicFunction +yarn twenty add frontComponent +``` + +### Available entity types + +| Entity type | Command | Generated file | +|-------------|---------|----------------| +| Object | `yarn twenty add object` | `src/objects/.ts` | +| Field | `yarn twenty add field` | `src/fields/.ts` | +| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/.ts` | +| Front component | `yarn twenty add frontComponent` | `src/front-components/.tsx` | +| Role | `yarn twenty add role` | `src/roles/.ts` | +| Skill | `yarn twenty add skill` | `src/skills/.ts` | +| Agent | `yarn twenty add agent` | `src/agents/.ts` | +| View | `yarn twenty add view` | `src/views/.ts` | +| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/.ts` | +| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/.ts` | + +### What the scaffolder generates + +Each entity type has its own template. For example, `yarn twenty add object` asks for: + +1. **Name (singular)** — e.g., `invoice` +2. **Name (plural)** — e.g., `invoices` +3. **Label (singular)** — auto-populated from the name (e.g., `Invoice`) +4. **Label (plural)** — auto-populated (e.g., `Invoices`) +5. **Create a view and navigation item?** — if you answer yes, the scaffolder also generates a matching view and sidebar link for the new object. + +Other entity types have simpler prompts — most only ask for a name. + +The `field` entity type is more detailed: it asks for the field name, label, type (from a list of all available field types like `TEXT`, `NUMBER`, `SELECT`, `RELATION`, etc.), and the target object's `universalIdentifier`. + +### Custom output path + +Use the `--path` flag to place the generated file in a custom location: + +```bash filename="Terminal" +yarn twenty add logicFunction --path src/custom-folder +``` + +## Typed API clients (twenty-client-sdk) + +The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components. | Client | Import | Endpoint | Generated? | |--------|--------|----------|------------| | `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — workspace data (records, objects) | Yes, at dev/build time | | `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — workspace config, file uploads | No, ships pre-built | -#### CoreApiClient + + -`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it's fully typed to match your objects and fields. +`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it is fully typed to match your objects and fields. -```typescript +```ts import { CoreApiClient } from 'twenty-client-sdk/core'; const client = new CoreApiClient(); @@ -827,7 +1353,10 @@ const { companies } = await client.query({ node: { id: true, name: true, - domainName: true, + domainName: { + primaryLinkLabel: true, + primaryLinkUrl: true, + }, }, }, }, @@ -850,14 +1379,14 @@ const { createCompany } = await client.mutation({ 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. -**CoreApiClient is generated at dev/build time.** If you try to use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema, generates a typed client using `@genql/cli`, writes the generated sources to `node_modules/twenty-client-sdk/dist/core/generated/`, and replaces the stubs in `node_modules/twenty-client-sdk/dist/core.mjs` and `node_modules/twenty-client-sdk/dist/core.cjs`. +**CoreApiClient is generated at dev/build time.** If you use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema and generates a typed client using `@genql/cli`. #### Using CoreSchema for type annotations -`CoreSchema` provides TypeScript types matching your workspace objects, useful for typing component state or function parameters: +`CoreSchema` provides TypeScript types matching your workspace objects — useful for typing component state or function parameters: -```typescript +```ts import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core'; import { useState } from 'react'; @@ -876,44 +1405,41 @@ const result = await client.query({ setCompany(result.company); ``` -#### MetadataApiClient + + -`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads: +`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads. -```typescript +```ts import { MetadataApiClient } from 'twenty-client-sdk/metadata'; const metadataClient = new MetadataApiClient(); -// Query workspace info -const { currentWorkspace } = await metadataClient.query({ - currentWorkspace: { id: true, displayName: true }, -}); - -// List installed applications -const { findManyApplications } = await metadataClient.query({ - findManyApplications: { - id: true, - name: true, - version: true, +// 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 }, + }, }, }); ``` -#### Runtime credentials - -When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables: - -- `TWENTY_API_URL` — Base URL of the Twenty API -- `TWENTY_API_KEY` — Short-lived key scoped to your application's default function role - -You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`. - #### Uploading files -`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields. It implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec): +`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields: -```typescript +```ts import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import * as fs from 'fs'; @@ -936,13 +1462,255 @@ console.log(uploadedFile); |-----------|------|-------------| | `fileBuffer` | `Buffer` | The raw file contents | | `filename` | `string` | The name of the file (used for storage and display) | -| `contentType` | `string` | MIME type of the file (defaults to `application/octet-stream` if omitted) | +| `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. -### Hello World example + + -Explore a minimal, end-to-end example that demonstrates objects, logic functions, front components, and multiple triggers [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world). + + + When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables: + + - `TWENTY_API_URL` — Base URL of the Twenty API + - `TWENTY_APP_ACCESS_TOKEN` — Short-lived key scoped to your application's default function role + + You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`. + + +## Testing your app + +The SDK provides programmatic APIs that let you build, deploy, install, and uninstall your app from test code. Combined with [Vitest](https://vitest.dev/) and the typed API clients, you can write integration tests that verify your app works end-to-end against a real Twenty server. + +### Setup + +The scaffolded app already includes Vitest. If you set it up manually, install the dependencies: + +```bash filename="Terminal" +yarn add -D vitest vite-tsconfig-paths +``` + +Create a `vitest.config.ts` at the root of your app: + +```ts vitest.config.ts +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], + test: { + testTimeout: 120_000, + hookTimeout: 120_000, + include: ['src/**/*.integration-test.ts'], + setupFiles: ['src/__tests__/setup-test.ts'], + env: { + TWENTY_API_URL: 'http://localhost:2020', + TWENTY_API_KEY: 'your-api-key', + }, + }, +}); +``` + +Create a setup file that verifies the server is reachable before tests run: + +```ts src/__tests__/setup-test.ts +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { beforeAll } from 'vitest'; + +const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020'; +const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test'); + +beforeAll(async () => { + // Verify the server is running + const response = await fetch(`${TWENTY_API_URL}/healthz`); + + if (!response.ok) { + throw new Error( + `Twenty server is not reachable at ${TWENTY_API_URL}. ` + + 'Start the server before running integration tests.', + ); + } + + // Write a temporary config for the SDK + fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true }); + + fs.writeFileSync( + path.join(TEST_CONFIG_DIR, 'config.json'), + JSON.stringify({ + remotes: { + local: { + apiUrl: process.env.TWENTY_API_URL, + apiKey: process.env.TWENTY_API_KEY, + }, + }, + defaultRemote: 'local', + }, null, 2), + ); +}); +``` + +### Programmatic SDK APIs + +The `twenty-sdk/cli` subpath exports functions you can call directly from test code: + +| Function | Description | +|----------|-------------| +| `appBuild` | Build the app and optionally pack a tarball | +| `appDeploy` | Upload a tarball to the server | +| `appInstall` | Install the app on the active workspace | +| `appUninstall` | Uninstall the app from the active workspace | + +Each function returns a result object with `success: boolean` and either `data` or `error`. + +### Writing an integration test + +Here is a full example that builds, deploys, and installs the app, then verifies it appears in the workspace: + +```ts src/__tests__/app-install.integration-test.ts +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'; +import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'; +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const APP_PATH = process.cwd(); + +describe('App installation', () => { + beforeAll(async () => { + const buildResult = await appBuild({ + appPath: APP_PATH, + tarball: true, + onProgress: (message: string) => console.log(`[build] ${message}`), + }); + + if (!buildResult.success) { + throw new Error(`Build failed: ${buildResult.error?.message}`); + } + + const deployResult = await appDeploy({ + tarballPath: buildResult.data.tarballPath!, + onProgress: (message: string) => console.log(`[deploy] ${message}`), + }); + + if (!deployResult.success) { + throw new Error(`Deploy failed: ${deployResult.error?.message}`); + } + + const installResult = await appInstall({ appPath: APP_PATH }); + + if (!installResult.success) { + throw new Error(`Install failed: ${installResult.error?.message}`); + } + }); + + afterAll(async () => { + await appUninstall({ appPath: APP_PATH }); + }); + + it('should find the installed app in the workspace', async () => { + const metadataClient = new MetadataApiClient(); + + const result = await metadataClient.query({ + findManyApplications: { + id: true, + name: true, + universalIdentifier: true, + }, + }); + + const installedApp = result.findManyApplications.find( + (app: { universalIdentifier: string }) => + app.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, + ); + + expect(installedApp).toBeDefined(); + }); +}); +``` + +### Running tests + +Make sure your local Twenty server is running, then: + +```bash filename="Terminal" +yarn test +``` + +Or in watch mode during development: + +```bash filename="Terminal" +yarn test:watch +``` + +### Type checking + +You can also run type checking on your app without running tests: + +```bash filename="Terminal" +yarn twenty typecheck +``` + +This runs `tsc --noEmit` and reports any type errors. + +## CLI reference + +Beyond `dev`, `build`, `add`, and `typecheck`, the CLI provides commands for executing functions, viewing logs, and managing app installations. + +### Executing functions (`yarn twenty exec`) + +Run a logic function manually without triggering it via HTTP, cron, or database event: + +```bash filename="Terminal" +# Execute by function name +yarn twenty exec -n create-new-post-card + +# Execute by universalIdentifier +yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf + +# Pass a JSON payload +yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}' + +# Execute pre-install or post-install functions +yarn twenty exec --preInstall +yarn twenty exec --postInstall +``` + +### Viewing function logs (`yarn twenty logs`) + +Stream execution logs for your app's logic functions: + +```bash filename="Terminal" +# Stream all function logs +yarn twenty logs + +# Filter by function name +yarn twenty logs -n create-new-post-card + +# Filter by universalIdentifier +yarn twenty logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf +``` + + +This is different from `yarn twenty server logs`, which shows the Docker container logs. `yarn twenty logs` shows your app's function execution logs from the Twenty server. + + +### Uninstalling an app (`yarn twenty uninstall`) + +Remove your app from the active workspace: + +```bash filename="Terminal" +yarn twenty uninstall + +# Skip the confirmation prompt +yarn twenty uninstall --yes +``` diff --git a/packages/twenty-docs/developers/extend/apps/getting-started.mdx b/packages/twenty-docs/developers/extend/apps/getting-started.mdx index ac6152bff6..3b0001b0f8 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started.mdx @@ -4,72 +4,142 @@ description: Create your first Twenty app in minutes. --- -Apps are currently in alpha testing. The feature is functional but still evolving. +Apps are currently in alpha. The feature works but is still evolving. Apps let you extend Twenty with custom objects, fields, logic functions, AI skills, and UI components — all managed as code. -**What you can build:** -- Custom objects, fields, views, and navigation items to shape your data model -- Logic functions triggered by HTTP routes, cron schedules, or database events -- Front components that render directly inside Twenty's UI -- Skills that extend Twenty's AI agents -- Deploy an app across multiple workspaces - ## Prerequisites -- Node.js 24+ -- Yarn 4 -- Docker (or a running local Twenty instance) +Before you begin, make sure the following is installed on your machine: -## Getting Started +- **Node.js 24+** — [Download here](https://nodejs.org/) +- **Yarn 4** — Comes with Node.js via Corepack. Enable it by running `corepack enable` +- **Docker** — [Download here](https://www.docker.com/products/docker-desktop/). Required to run a local Twenty instance. Not needed if you already have a Twenty server running. -Create a new app using the official scaffolder, then authenticate and start developing: +## Step 1: Scaffold your app + +Open a terminal and run: ```bash filename="Terminal" -# Scaffold a new app (includes all examples by default) npx create-twenty-app@latest my-twenty-app ``` -> Use `--minimal` option to scaffold a minimal installation +You will be prompted to enter a name and a description for your app. Press **Enter** to accept the defaults. -From here you can: +This creates a new folder called `my-twenty-app` with everything you need. + + +The scaffolder supports these flags: + +- `--minimal` — scaffold only the essential files, no examples (default) +- `--exhaustive` — scaffold all example entities +- `--name ` — set the app name (skips the prompt) +- `--display-name ` — set the display name (skips the prompt) +- `--description ` — set the description (skips the prompt) +- `--skip-local-instance` — skip the local server setup prompt + + +## Step 2: Set up a local Twenty instance + +The scaffolder will ask: + +> **Would you like to set up a local Twenty instance?** + +- **Type `yes`** (recommended) — This pulls the `twenty-app-dev` Docker image and starts a local Twenty server on port `2020`. Make sure Docker is running before you continue. +- **Type `no`** — Choose this if you already have a Twenty server running locally. + +
+ Should start local instance? +
+ +## Step 3: Sign in to your workspace + +Next, a browser window will open with the Twenty login page. Sign in with the pre-seeded demo account: + +- **Email:** `tim@apple.dev` +- **Password:** `tim@apple.dev` + +
+ Twenty login screen +
+ +## Step 4: Authorize the app + +After you sign in, you will see an authorization screen. This lets your app interact with your workspace. + +Click **Authorize** to continue. + +
+ Twenty CLI authorization screen +
+ +Once authorized, your terminal will confirm that everything is set up. + +
+ App scaffolded successfully +
+ +## Step 5: Start developing + +Go into your new app folder and start the development server: ```bash filename="Terminal" -# Add a new entity to your application (guided) -yarn twenty add - -# Watch your application's function logs -yarn twenty function:logs - -# Execute a function by name -yarn twenty function:execute -n my-function -p '{"name": "test"}' - -# Execute the pre-install function -yarn twenty function:execute --preInstall - -# Execute the post-install function -yarn twenty function:execute --postInstall - -# Uninstall the application from the current workspace -yarn twenty uninstall - -# Display commands' help -yarn twenty help +cd my-twenty-app +yarn twenty dev ``` -See also: the CLI reference pages for [create-twenty-app](https://www.npmjs.com/package/create-twenty-app) and [twenty-sdk CLI](https://www.npmjs.com/package/twenty-sdk). +This watches your source files, rebuilds on every change, and syncs your app to the local Twenty server automatically. You should see a live status panel in your terminal. -## Project structure (scaffolded) +For more detailed output (build logs, sync requests, error traces), use the `--verbose` flag: -When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder: +```bash filename="Terminal" +yarn twenty dev --verbose +``` -- Copies a minimal base application into `my-twenty-app/` -- Adds a local `twenty-sdk` dependency and Yarn 4 configuration -- Creates config files and scripts wired to the `twenty` CLI -- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode + +Dev mode is only available on Twenty instances running in development (`NODE_ENV=development`). Production instances reject dev sync requests. Use `yarn twenty deploy` to deploy to production servers — see [Publishing Apps](/developers/extend/apps/publishing) for details. + -A freshly scaffolded app with the default `--exhaustive` mode looks like this: +
+ Dev mode terminal output +
+ +## Step 6: See your app in Twenty + +Open [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer) in your browser. Navigate to **Settings > Apps** and select the **Developer** tab. You should see your app listed under **Your Apps**: + +
+ Your Apps list showing My twenty app +
+ +Click on **My twenty app** to open its **application registration**. A registration is a server-level record that describes your app — its name, unique identifier, OAuth credentials, and source (local, npm, or tarball). It lives on the server, not inside any specific workspace. When you install an app into a workspace, Twenty creates a workspace-scoped **application** that points back to this registration. One registration can be installed across multiple workspaces on the same server. + +
+ Application registration details +
+ +Click **View installed app** to see the installed app. The **About** tab shows the current version and management options: + +
+ Installed app — About tab +
+ +Switch to the **Content** tab to see everything your app provides — objects, fields, logic functions, and agents: + +
+ Installed app — Content tab +
+ +You are all set! Edit any file in `src/` and the changes will be picked up automatically. + +Head over to [Building Apps](/developers/extend/apps/building) for a detailed guide on creating objects, logic functions, front components, skills, and more. + +--- + +## Project structure + +The scaffolder generates the following file structure (shown with `--exhaustive` mode, which includes examples for every entity type): ```text filename="my-twenty-app/" my-twenty-app/ @@ -82,123 +152,238 @@ my-twenty-app/ install-state.gz .oxlintrc.json tsconfig.json + tsconfig.spec.json # TypeScript config for tests + vitest.config.ts # Vitest test runner configuration + LLMS.md README.md - public/ # Public assets folder (images, fonts, etc.) + .github/ + └── workflows/ + └── ci.yml # GitHub Actions CI workflow + public/ # Public assets (images, fonts, etc.) src/ - ├── application-config.ts # Required - main application configuration + ├── application-config.ts # Required — main application configuration + ├── __tests__/ + │ ├── setup-test.ts # Test setup (server health check, config) + │ └── app-install.integration-test.ts # Example integration test ├── roles/ - │ └── default-role.ts # Default role for logic functions + │ └── default-role.ts # Default role for logic functions ├── objects/ - │ └── example-object.ts # Example custom object definition + │ └── example-object.ts # Example custom object definition ├── fields/ - │ └── example-field.ts # Example standalone field definition + │ └── example-field.ts # Example standalone field definition ├── logic-functions/ - │ ├── hello-world.ts # Example logic function - │ ├── pre-install.ts # Pre-install logic function - │ └── post-install.ts # Post-install logic function + │ ├── hello-world.ts # Example logic function + │ ├── create-hello-world-company.ts # Example logic function using CoreApiClient + │ ├── pre-install.ts # Runs before installation + │ └── post-install.ts # Runs after installation ├── front-components/ - │ └── hello-world.tsx # Example front component + │ └── hello-world.tsx # Example front component + ├── page-layouts/ + │ └── example-record-page-layout.ts # Example page layout with front component ├── views/ - │ └── example-view.ts # Example saved view definition + │ └── example-view.ts # Example saved view definition ├── navigation-menu-items/ │ └── example-navigation-menu-item.ts # Example sidebar navigation link - └── skills/ - └── example-skill.ts # Example AI agent skill definition + ├── skills/ + │ └── example-skill.ts # Example AI agent skill definition + └── agents/ + └── example-agent.ts # Example AI agent definition ``` -With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). +By default (`--minimal`), only the core files are created: `application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`. Use `--exhaustive` to include all the example files shown above. -At a high level: +### Key files -- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands. -- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `.twenty/`, `dist/`, `build/`, coverage folders, log files, and `.env*` files. -- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project. -- **.nvmrc**: Pins the Node.js version expected by the project. -- **.oxlintrc.json** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources. -- **README.md**: A short README in the app root with basic instructions. -- **public/**: A folder for storing public assets (images, fonts, static files) that will be served with your application. Files placed here are uploaded during sync and accessible at runtime. -- **src/**: The main place where you define your application-as-code +| File / Folder | Purpose | +|---|---| +| `package.json` | Declares your app name, version, and dependencies. Includes a `twenty` script so you can run `yarn twenty help` to see all commands. | +| `src/application-config.ts` | **Required.** The main configuration file for your app. | +| `src/roles/` | Defines roles that control what your logic functions can access. | +| `src/logic-functions/` | Server-side functions triggered by routes, cron schedules, or database events. | +| `src/front-components/` | React components that render inside Twenty's UI. | +| `src/objects/` | Custom object definitions to extend your data model. | +| `src/fields/` | Custom fields added to existing objects. | +| `src/views/` | Saved view configurations. | +| `src/navigation-menu-items/` | Custom links in the sidebar navigation. | +| `src/skills/` | Skills that extend Twenty's AI agents. | +| `src/agents/` | AI agents with custom prompts. | +| `src/page-layouts/` | Custom page layouts for record views. | +| `src/__tests__/` | Integration tests (setup + example test). | +| `public/` | Static assets (images, fonts) served with your app. | -### Entity detection +## Managing remotes -The SDK detects entities by parsing your TypeScript files for **`export default define({...})`** calls. Each entity type has a corresponding helper function exported from `twenty-sdk`: - -| Helper function | Entity type | -|-----------------|-------------| -| `defineObject` | Custom object definitions | -| `defineLogicFunction` | Logic function definitions | -| `definePreInstallLogicFunction` | Pre-install logic function (runs before installation) | -| `definePostInstallLogicFunction` | Post-install logic function (runs after installation) | -| `defineFrontComponent` | Front component definitions | -| `defineRole` | Role definitions | -| `defineField` | Field extensions for existing objects | -| `defineView` | Saved view definitions | -| `defineNavigationMenuItem` | Navigation menu item definitions | -| `defineSkill` | AI agent skill definitions | - - -**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement. - - -Example of a detected entity: -```typescript -// This file can be named anything and placed anywhere in src/ -import { defineObject, FieldType } from 'twenty-sdk'; - -export default defineObject({ - universalIdentifier: '...', - nameSingular: 'postCard', - // ... rest of config -}); -``` - -Later commands will add more files and folders: - -- `yarn twenty dev` will auto-generate the typed `CoreApiClient` (for workspace data via `/graphql`) into `node_modules/twenty-client-sdk/`. The `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built and is available immediately. Import them from `twenty-client-sdk/core` and `twenty-client-sdk/metadata` respectively. -- `yarn twenty add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more. - -## Authentication - -The first time you run `yarn twenty auth:login`, you'll be prompted for: - -- API URL (defaults to http://localhost:3000 or your current workspace profile) -- API key - -Your credentials are stored per-user in `~/.twenty/config.json`. You can maintain multiple profiles and switch between them. - -### Managing workspaces +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" -# Login interactively (recommended) -yarn twenty auth:login +# Add a new remote (opens a browser for OAuth login) +yarn twenty remote add -# Login to a specific workspace profile -yarn twenty auth:login --workspace my-custom-workspace +# Connect to a local Twenty server (auto-detects port 2020 or 3000) +yarn twenty remote add --local -# List all configured workspaces -yarn twenty auth:list +# Add a remote non-interactively (useful for CI) +yarn twenty remote add --api-url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote -# Switch the default workspace (interactive) -yarn twenty auth:switch +# List all configured remotes +yarn twenty remote list -# Switch to a specific workspace -yarn twenty auth:switch production - -# Check current authentication status -yarn twenty auth:status +# Switch the active remote +yarn twenty remote switch ``` -Once you've switched workspaces with `yarn twenty auth:switch`, all subsequent commands will use that workspace by default. You can still override it temporarily with `--workspace `. +Your credentials are stored in `~/.twenty/config.json`. + +## Local development server (`yarn twenty server`) + +The CLI can manage a local Twenty server running in Docker. This is the same server started automatically when you scaffold an app with `create-twenty-app`, but you can also manage it manually. + +### Starting the server + +```bash filename="Terminal" +yarn twenty server start +``` + +This pulls the `twentycrm/twenty-app-dev:latest` Docker image (if not already present), creates a container named `twenty-app-dev`, and starts it on port **2020**. The CLI waits until the server passes its health check before returning. + +Two Docker volumes are created to persist data between restarts: +- `twenty-app-dev-data` — PostgreSQL database +- `twenty-app-dev-storage` — file storage + +If port 2020 is already in use, you can start on a different port: + +```bash filename="Terminal" +yarn twenty server start --port 3030 +``` + +The CLI automatically configures the container's internal `NODE_PORT` and `SERVER_URL` to match the chosen port, so logic functions, OAuth, and all other internal networking work correctly. + +Once started, the server is automatically registered as the `local` remote in your CLI config. + +### Checking server status + +```bash filename="Terminal" +yarn twenty server status +``` + +Displays whether the server is running, its URL, and the default login credentials (`tim@apple.dev` / `tim@apple.dev`). + +### Viewing server logs + +```bash filename="Terminal" +yarn twenty server logs +``` + +Streams the container logs. Use `--lines` to control how many recent lines to show: + +```bash filename="Terminal" +yarn twenty server logs --lines 100 +``` + +### Stopping the server + +```bash filename="Terminal" +yarn twenty server stop +``` + +Stops the container. Your data is preserved in the Docker volumes — the next `start` picks up where you left off. + +### Resetting the server + +```bash filename="Terminal" +yarn twenty server reset +``` + +Removes the container **and** deletes both Docker volumes, wiping all data. The next `start` creates a fresh instance. + + + The server requires **Docker** to be running. If you see a "Docker not running" error, make sure Docker Desktop (or the Docker daemon) is started. + + +### Command reference + +| Command | Description | +|---------|-------------| +| `yarn twenty server start` | Start the local server (pulls image if needed) | +| `yarn twenty server start --port 3030` | Start on a custom port | +| `yarn twenty server stop` | Stop the server (preserves data) | +| `yarn twenty server status` | Show server status, URL, and credentials | +| `yarn twenty server logs` | Stream server logs | +| `yarn twenty server logs --lines 100` | Show the last 100 log lines | +| `yarn twenty server reset` | Delete all data and start fresh | + + +## CI with GitHub Actions + +The scaffolder generates a ready-to-use GitHub Actions workflow at `.github/workflows/ci.yml`. It runs your integration tests automatically on every push to `main` and on pull requests. + +The workflow: + +1. Checks out your code +2. Spins up a temporary Twenty server using the `twentyhq/twenty/.github/actions/spawn-twenty-docker-image` action +3. Installs dependencies with `yarn install --immutable` +4. Runs `yarn test` with `TWENTY_API_URL` and `TWENTY_API_KEY` injected from the action outputs + +```yaml .github/workflows/ci.yml +name: CI + +on: + push: + branches: + - main + pull_request: {} + +env: + TWENTY_VERSION: latest + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Spawn Twenty instance + id: twenty + uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main + with: + twenty-version: ${{ env.TWENTY_VERSION }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable Corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --immutable + + - name: Run integration tests + run: yarn test + env: + TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }} + TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }} +``` + +You don't need to configure any secrets — the `spawn-twenty-docker-image` action starts an ephemeral Twenty server directly in the runner and outputs the connection details. The `GITHUB_TOKEN` secret is provided automatically by GitHub. + +To pin a specific Twenty version instead of `latest`, change the `TWENTY_VERSION` environment variable at the top of the workflow. ## Manual setup (without the scaffolder) -While we recommend using `create-twenty-app` for the best getting-started experience, you can also set up a project manually. Do not install the CLI globally. Instead, add `twenty-sdk` as a local dependency and wire a single script in your package.json: +If you prefer to set things up yourself instead of using `create-twenty-app`, you can do it in two steps. + +**1. Add `twenty-sdk` and `twenty-client-sdk` as dependencies:** ```bash filename="Terminal" -yarn add -D twenty-sdk +yarn add twenty-sdk twenty-client-sdk ``` -Then add a `twenty` script: +**2. Add a `twenty` script to your `package.json`:** ```json filename="package.json" { @@ -208,25 +393,19 @@ Then add a `twenty` script: } ``` -Now you can run all commands via `yarn twenty `, e.g. `yarn twenty dev`, `yarn twenty help`, etc. +You can now run `yarn twenty dev`, `yarn twenty help`, and all other commands. -## How to use a local Twenty instance - -If you're already running a Twenty instance locally (e.g. via `npx nx start twenty-server`), you can connect to it instead of using Docker: - -```bash filename="Terminal" -# During scaffolding — skip Docker, connect to your running instance -npx create-twenty-app@latest my-app --port 3000 - -# Or after scaffolding — add a remote pointing to your instance -yarn twenty remote add --local --port 3000 -``` + +Do not install `twenty-sdk` globally. Always use it as a local project dependency so that each project can pin its own version. + ## Troubleshooting -- Authentication errors: run `yarn twenty auth:login` and ensure your API key has the required permissions. -- Cannot connect to server: verify the API URL and that the Twenty server is reachable. -- Types or client missing/outdated: restart `yarn twenty dev` — it auto-generates the typed client. -- Dev mode not syncing: ensure `yarn twenty dev` is running and that changes are not ignored by your environment. +If you run into issues: -Discord Help Channel: https://discord.com/channels/1130383047699738754/1130386664812982322 +- Make sure **Docker is running** before starting the scaffolder with a local instance. +- Make sure you are using **Node.js 24+** (`node -v` to check). +- Make sure **Corepack is enabled** (`corepack enable`) so Yarn 4 is available. +- Try deleting `node_modules` and running `yarn install` again if dependencies seem broken. + +Still stuck? Ask for help on the [Twenty Discord](https://discord.com/channels/1130383047699738754/1130386664812982322). diff --git a/packages/twenty-docs/developers/extend/apps/publishing.mdx b/packages/twenty-docs/developers/extend/apps/publishing.mdx index a7e1f42c31..c3f8bd3ab4 100644 --- a/packages/twenty-docs/developers/extend/apps/publishing.mdx +++ b/packages/twenty-docs/developers/extend/apps/publishing.mdx @@ -4,34 +4,76 @@ description: Distribute your Twenty app to the marketplace or deploy it internal --- -Apps are currently in alpha testing. The feature is functional but still evolving. + Apps are currently in alpha. The feature works but is still evolving. ## Overview Once your app is [built and tested locally](/developers/extend/apps/building), you have two paths for distributing it: -- **Publish to npm** — list your app in the Twenty marketplace for any workspace to discover and install. - **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. ## Building your app -The `build` command compiles your TypeScript sources, transpiles logic functions and front components, and generates a `manifest.json` that describes your app's contents: +Run the build command to compile your app and generate a distribution-ready `manifest.json`: ```bash filename="Terminal" yarn twenty build ``` -The output is written to `.twenty/output/`. This directory contains everything needed for distribution: compiled code, assets, the manifest, and a copy of your `package.json`. +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 deploy command. -To also create a `.tgz` tarball (used by the deploy command internally, or for manual distribution): +## 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 build --tarball +yarn twenty remote add --api-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 deploy +# To deploy to a specific remote: +# yarn twenty deploy --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. + + +Sharing private apps is an Enterprise feature. Go to [Settings > Admin Panel > Enterprise](/settings/admin-panel#enterprise) to enable it. + + +### Version management + +To release an update: + +1. Bump the `version` field in your `package.json` +2. Run `yarn twenty deploy` (or `yarn twenty deploy --remote production`) +3. Workspaces that have the app installed will see the upgrade available in their settings + +{/* TODO: add screenshot of the Upgrade button */} + ## 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. @@ -39,41 +81,42 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe ### Requirements - An [npm](https://www.npmjs.com) account -- The `twenty-app` keyword **must** be listed in your `package.json` `keywords` array - -### Adding the required keyword - -The Twenty marketplace discovers apps by searching the npm registry for packages with the `twenty-app` keyword. Add it to your `package.json`: +- The `twenty-app` keyword in your `package.json` `keywords` array (already included when you scaffold with `create-twenty-app`) ```json filename="package.json" { "name": "twenty-app-postcard-sender", "version": "1.0.0", - "keywords": ["twenty-app"], - ... + "keywords": ["twenty-app"] } ``` - -The marketplace searches for `keywords:twenty-app` on the npm registry. Without this keyword, your package won't appear in the marketplace even if it has the `twenty-app-` name prefix. - +### Marketplace metadata -### Steps +The `defineApplication()` config supports optional fields that control how your app appears in the marketplace. Use `logoUrl` and `screenshots` to reference images from the `public/` folder: -1. **Build your app:** - -```bash filename="Terminal" -yarn twenty build +```ts src/application-config.ts +export default defineApplication({ + universalIdentifier: '...', + displayName: 'My App', + description: 'A great app', + defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + logoUrl: 'public/logo.png', + screenshots: [ + 'public/screenshot-1.png', + 'public/screenshot-2.png', + ], +}); ``` -2. **Publish to npm:** +See the [defineApplication accordion](/developers/extend/apps/building#defineentity-functions) in the Building Apps page for the full list of marketplace fields (`author`, `category`, `aboutDescription`, `websiteUrl`, `termsUrl`, etc.). + +### Publish ```bash filename="Terminal" yarn twenty publish ``` -This runs `npm publish` from the `.twenty/output/` directory. - To publish under a specific dist-tag (e.g., `beta` or `next`): ```bash filename="Terminal" @@ -82,25 +125,17 @@ yarn twenty publish --tag beta ### How marketplace discovery works -The Twenty server syncs its marketplace catalog from the npm registry **every hour**: +The Twenty server syncs its marketplace catalog from the npm registry **every hour**. -1. It searches for all npm packages with the `keywords:twenty-app` keyword -2. For each package, it fetches the `manifest.json` from the npm CDN -3. The app's metadata (name, description, author, logo, screenshots, category) is extracted from the manifest and displayed in the marketplace - -After publishing, your app can take up to one hour to appear in the marketplace. To trigger the sync immediately instead of waiting for the next hourly run: +You can trigger the sync immediately instead of waiting: ```bash filename="Terminal" yarn twenty catalog-sync +# To target a specific remote: +# yarn twenty catalog-sync --remote production ``` -To target a specific remote: - -```bash filename="Terminal" -yarn twenty catalog-sync -r production -``` - -The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`. +The metadata shown in the marketplace comes from your `defineApplication()` config — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`. 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`. @@ -108,7 +143,7 @@ If your app does not define an `aboutDescription` in `defineApplication()`, the ### CI publishing -The scaffolded project includes a GitHub Actions workflow that publishes on every release: +Use this GitHub Actions workflow to publish automatically on every release (uses [OIDC](https://docs.npmjs.com/trusted-publishers)): ```yaml filename=".github/workflows/publish.yml" name: Publish @@ -133,121 +168,24 @@ jobs: - run: npx twenty build - run: npm publish --provenance --access public working-directory: .twenty/output - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty build`, then `npm publish` from `.twenty/output`. - + **npm provenance** is optional but recommended. Publishing with `--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. See the [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements) for setup instructions. - - -## 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 -``` - -For a local development server: - -```bash filename="Terminal" -yarn twenty remote add --local --as local -``` - -You can also authenticate with an API key for non-interactive environments: - -```bash filename="Terminal" -yarn twenty remote add --url https://your-twenty-server.com --token --as production -``` - -Manage your remotes: - -```bash filename="Terminal" -yarn twenty remote list # List all configured remotes -yarn twenty remote switch prod # Set the default remote -yarn twenty remote status # Show active remote and auth status -yarn twenty remote remove old # Remove a remote -``` - -### Deploying - -Build and upload your app to the server in one step: - -```bash filename="Terminal" -yarn twenty deploy -``` - -This builds the app with `--tarball`, then uploads the tarball to the default remote via a GraphQL multipart upload. - -To deploy to a specific remote: - -```bash filename="Terminal" -yarn twenty deploy -r 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 - -To release an update: - -1. Bump the `version` field in your `package.json` -2. Run `yarn twenty deploy` (or `yarn twenty deploy -r production`) -3. Workspaces that have the app installed will see the upgrade available in their settings + ## Installing apps -Once an app is published (npm) or deployed (tarball), workspaces install it through the UI: +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 install ``` - -Or from the **Settings > Applications** page in the Twenty UI, where both marketplace and tarball-deployed apps can be browsed and installed. - -## App distribution categories - -Twenty organizes apps into three categories based on how they're distributed: - -| Category | How it works | Visible in marketplace? | -|----------|-------------|------------------------| -| **Development** | Local dev mode apps running via `yarn twenty dev`. Used for building and testing. | No | -| **Published (npm)** | Apps published to npm with the `twenty-app` keyword. Listed in the marketplace for any workspace to install. | Yes | -| **Internal (tarball)** | Apps deployed via tarball to a specific server. Available only to workspaces on that server via a share link. | No | - - -Start in **Development** mode while building your app. When it's ready, choose **Published** (npm) for broad distribution or **Internal** (tarball) for private deployment. - - -## CLI reference - -| Command | Description | Key flags | -|---------|-------------|-----------| -| `yarn twenty build` | Compile app and generate manifest | `--tarball` — also create a `.tgz` package | -| `yarn twenty publish` | Build and publish to npm | `--tag ` — npm dist-tag (e.g., `beta`, `next`) | -| `yarn twenty deploy` | Build and upload tarball to a server | `-r, --remote ` — target remote | -| `yarn twenty catalog-sync` | Trigger marketplace catalog sync on the server | `-r, --remote ` — target remote | -| `yarn twenty install` | Install a deployed app on a workspace | `-r, --remote ` — target remote | -| `yarn twenty dev` | Watch and sync local changes | Uses default remote | -| `yarn twenty remote add` | Add a server connection | `--url`, `--token`, `--as`, `--local`, `--port` | -| `yarn twenty remote list` | List configured remotes | — | -| `yarn twenty remote switch` | Set default remote | — | -| `yarn twenty remote status` | Show connection status | — | -| `yarn twenty remote remove` | Remove a remote | — | diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index 3b6de5319a..668c769152 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -4,1408 +4,78 @@ description: Build and manage Twenty customizations as code. --- -Apps are currently in alpha testing. The feature is functional but still evolving. +Apps are currently in alpha. The feature works but is still evolving. -## What Are Apps? +## What are apps? -Apps let you build and manage Twenty customizations **as code**. Instead of configuring everything through the UI, you define your data model and logic functions in code — making it faster to build, maintain, and roll out to multiple workspaces. +Apps let you extend Twenty with custom objects, fields, logic functions, front components, AI skills, and more — all managed as code. Instead of configuring everything through the UI, you define your data model and logic in TypeScript and deploy it to one or more workspaces. -**What you can do today:** -- Define custom objects and fields as code (managed data model) -- Build logic functions with custom triggers -- Define skills and agents for AI -- Deploy the same app across multiple workspaces +**What you can build:** -## Prerequisites +- **Custom objects and fields** — extend your data model with new entities or add fields to existing objects like Company or Person +- **Logic functions** — server-side functions triggered by database events, cron schedules, or HTTP routes +- **Front components** — React components that render inside Twenty's UI (record pages, command menu, side panels) +- **AI skills and agents** — extend Twenty's AI with custom capabilities +- **Views and navigation** — preconfigured saved views and sidebar links -- Node.js 24+ and Yarn 4 -- Docker (for the local Twenty dev server) - -## Getting Started - -Create a new app using the official scaffolder. It can automatically start a local Twenty instance for you: +## Quick start ```bash filename="Terminal" -# Scaffold a new app — the CLI will offer to start a local Twenty server npx create-twenty-app@latest my-twenty-app cd my-twenty-app - -# Start dev mode: automatically syncs local changes to your workspace yarn twenty dev ``` -### Local Server Management +This scaffolds a new app, optionally starts a local Twenty server, and begins watching your files for changes. See the [Getting Started](/developers/extend/apps/getting-started) guide for the full walkthrough. -The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000): +## Detailed guides -```bash filename="Terminal" -# Start the local server (pulls the image if needed) -yarn twenty server start +| Guide | Description | +|-------|-------------| +| [Getting Started](/developers/extend/apps/getting-started) | Scaffold an app, set up a local server, project structure, CI | +| [Building Apps](/developers/extend/apps/building) | Entity definitions (`defineObject`, `defineLogicFunction`, `defineFrontComponent`, etc.), API clients, npm packages, public assets, testing | +| [Publishing](/developers/extend/apps/publishing) | Deploy to a server, publish to npm, marketplace | -# Check server status -yarn twenty server status - -# Stream server logs -yarn twenty server logs - -# Stop the server -yarn twenty server stop - -# Reset all data and start fresh -yarn twenty server reset -``` - -The local server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`), so you can start developing immediately without any manual setup. - -### Authentication - -Connect your app to the local server using OAuth: - -```bash filename="Terminal" -# Authenticate via OAuth (opens browser) -yarn twenty remote add --local -``` - -The scaffolder supports two modes for controlling which example files are included: - -```bash filename="Terminal" -# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent) -npx create-twenty-app@latest my-app - -# Minimal: only core files (application-config.ts and default-role.ts) -npx create-twenty-app@latest my-app --minimal -``` - -### How to use a local Twenty instance - -If you're already running a local Twenty instance, you can connect to it instead of using Docker. Pass the port your local server is listening on (default: `3000`): - -```bash filename="Terminal" -# During scaffolding -npx create-twenty-app@latest my-app --port 3000 - -# Or after scaffolding -yarn twenty remote add --local --port 3000 -``` - -From here you can: - -```bash filename="Terminal" -# Add a new entity to your application (guided) -yarn twenty entity:add - -# Watch your application's function logs -yarn twenty function:logs - -# Execute a function by name -yarn twenty function:execute -n my-function -p '{"name": "test"}' - -# Execute the pre-install function -yarn twenty function:execute --preInstall - -# Execute the post-install function -yarn twenty function:execute --postInstall - -# Build the app for distribution -yarn twenty build - -# Publish the app to npm or a Twenty server -yarn twenty publish - -# Uninstall the application from the current workspace -yarn twenty uninstall - -# Display commands' help -yarn twenty help -``` - -See also: the CLI reference pages for [create-twenty-app](https://www.npmjs.com/package/create-twenty-app) and [twenty-sdk CLI](https://www.npmjs.com/package/twenty-sdk). - -## Project structure (scaffolded) - -When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder: - -- Copies a minimal base application into `my-twenty-app/` -- Adds a local `twenty-sdk` dependency and Yarn 4 configuration -- Creates config files and scripts wired to the `twenty` CLI -- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode - -A freshly scaffolded app with the default `--exhaustive` mode looks like this: - -```text filename="my-twenty-app/" -my-twenty-app/ - package.json - yarn.lock - .gitignore - .nvmrc - .yarnrc.yml - .yarn/ - install-state.gz - .oxlintrc.json - tsconfig.json - README.md - public/ # Public assets folder (images, fonts, etc.) - src/ - ├── application-config.ts # Required - main application configuration - ├── roles/ - │ └── default-role.ts # Default role for logic functions - ├── objects/ - │ └── example-object.ts # Example custom object definition - ├── fields/ - │ └── example-field.ts # Example standalone field definition - ├── logic-functions/ - │ ├── hello-world.ts # Example logic function - │ ├── pre-install.ts # Pre-install logic function - │ └── post-install.ts # Post-install logic function - ├── front-components/ - │ └── hello-world.tsx # Example front component - ├── views/ - │ └── example-view.ts # Example saved view definition - ├── navigation-menu-items/ - │ └── example-navigation-menu-item.ts # Example sidebar navigation link - ├── skills/ - │ └── example-skill.ts # Example AI agent skill definition - └── agents/ - └── example-agent.ts # Example AI agent definition -``` - -With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). - -At a high level: - -- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands. -- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `generated/` (typed client), `dist/`, `build/`, coverage folders, log files, and `.env*` files. -- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project. -- **.nvmrc**: Pins the Node.js version expected by the project. -- **.oxlintrc.json** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources. -- **README.md**: A short README in the app root with basic instructions. -- **public/**: A folder for storing public assets (images, fonts, static files) that will be served with your application. Files placed here are uploaded during sync and accessible at runtime. -- **src/**: The main place where you define your application-as-code +## Key concepts ### Entity detection -The SDK detects entities by parsing your TypeScript files for **`export default define({...})`** calls. Each entity type has a corresponding helper function exported from `twenty-sdk`: +The SDK detects entities by scanning your TypeScript files for `export default define({...})` calls. File naming and folder structure are flexible — detection is AST-based, not path-based. -| Helper function | Entity type | -|-----------------|-------------| -| `defineObject()` | Custom object definitions | -| `defineLogicFunction()` | Logic function definitions | -| `definePreInstallLogicFunction()` | Pre-install logic function (runs before installation) | -| `definePostInstallLogicFunction()` | Post-install logic function (runs after installation) | -| `defineFrontComponent()` | Front component definitions | -| `defineRole()` | Role definitions | -| `defineField()` | Field extensions for existing objects | -| `defineView()` | Saved view definitions | -| `defineNavigationMenuItem()` | Navigation menu item definitions | -| `defineSkill()` | AI agent skill definitions | -| `defineAgent()` | AI agent definitions | - - -**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement. - - -Example of a detected entity: -```typescript -// This file can be named anything and placed anywhere in src/ -import { defineObject, FieldType } from 'twenty-sdk'; - -export default defineObject({ - universalIdentifier: '...', - nameSingular: 'postCard', - // ... rest of config -}); -``` - -Later commands will add more files and folders: - -- `yarn twenty dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/clients`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`). -- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more. - -## Authentication - -The first time you run `yarn twenty auth:login`, you'll be prompted for: - -- API URL (defaults to http://localhost:3000 or your current workspace profile) -- API key - -Your credentials are stored per-user in `~/.twenty/config.json`. You can maintain multiple profiles and switch between them. - -### Managing workspaces - -```bash filename="Terminal" -# Login interactively (recommended) -yarn twenty auth:login - -# Login to a specific workspace profile -yarn twenty auth:login --workspace my-custom-workspace - -# List all configured workspaces -yarn twenty auth:list - -# Switch the default workspace (interactive) -yarn twenty auth:switch - -# Switch to a specific workspace -yarn twenty auth:switch production - -# Check current authentication status -yarn twenty auth:status -``` - -Once you've switched workspaces with `yarn twenty auth:switch`, all subsequent commands will use that workspace by default. You can still override it temporarily with `--workspace `. - -## Use the SDK resources (types & config) - -The twenty-sdk provides typed building blocks and helper functions you use inside your app. Below are the key pieces you'll touch most often. - -### Helper functions - -The SDK provides helper functions for defining your app entities. As described in [Entity detection](#entity-detection), you must use `export default define({...})` for your entities to be detected: +### Available entity types | Function | Purpose | |----------|---------| -| `defineApplication()` | Configure application metadata (required, one per app) | -| `defineObject()` | Define custom objects with fields | -| `defineLogicFunction()` | Define logic functions with handlers | -| `definePreInstallLogicFunction()` | Define a pre-install logic function (one per app) | -| `definePostInstallLogicFunction()` | Define a post-install logic function (one per app) | -| `defineFrontComponent()` | Define front components for custom UI | -| `defineRole()` | Configure role permissions and object access | -| `defineField()` | Extend existing objects with additional fields | -| `defineView()` | Define saved views for objects | -| `defineNavigationMenuItem()` | Define sidebar navigation links | -| `defineSkill()` | Define AI agent skills | -| `defineAgent()` | Define AI agents with system prompts | +| `defineApplication()` | Application metadata (required, one per app) | +| `defineObject()` | Custom objects with fields | +| `defineField()` | Fields on existing objects | +| `defineLogicFunction()` | Server-side logic with triggers | +| `defineFrontComponent()` | React components in Twenty's UI | +| `defineRole()` | Permission roles | +| `defineView()` | Saved view configurations | +| `defineNavigationMenuItem()` | Sidebar navigation links | +| `defineSkill()` | AI agent skills | +| `defineAgent()` | AI agents with prompts | +| `definePageLayout()` | Custom record page layouts | +| `definePreInstallLogicFunction()` | Runs before app installation | +| `definePostInstallLogicFunction()` | Runs after app installation | -These functions validate your configuration at build time and provide IDE autocompletion and type safety. +### Development workflow -### Defining objects +1. **`yarn twenty dev`** — watches source files, rebuilds on change, syncs to the server, generates typed API clients +2. **`yarn twenty build`** — produces a distributable build +3. **`yarn twenty deploy`** — deploys to a remote Twenty server +4. **`yarn twenty add`** — scaffolds a new entity interactively -Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation: - -```typescript -// src/app/postCard.object.ts -import { defineObject, FieldType } from 'twenty-sdk'; - -enum PostCardStatus { - DRAFT = 'DRAFT', - SENT = 'SENT', - DELIVERED = 'DELIVERED', - RETURNED = 'RETURNED', -} - -export default defineObject({ - universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', - nameSingular: 'postCard', - namePlural: 'postCards', - labelSingular: 'Post Card', - labelPlural: 'Post Cards', - description: 'A post card object', - icon: 'IconMail', - fields: [ - { - universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', - name: 'content', - type: FieldType.TEXT, - label: 'Content', - description: "Postcard's content", - icon: 'IconAbc', - }, - { - universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac', - name: 'recipientName', - type: FieldType.FULL_NAME, - label: 'Recipient name', - icon: 'IconUser', - }, - { - universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266', - name: 'recipientAddress', - type: FieldType.ADDRESS, - label: 'Recipient address', - icon: 'IconHome', - }, - { - universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e', - name: 'status', - type: FieldType.SELECT, - label: 'Status', - icon: 'IconSend', - defaultValue: `'${PostCardStatus.DRAFT}'`, - options: [ - { value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' }, - { value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' }, - { value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' }, - { value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' }, - ], - }, - { - universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433', - name: 'deliveredAt', - type: FieldType.DATE_TIME, - label: 'Delivered at', - icon: 'IconCheck', - isNullable: true, - defaultValue: null, - }, - ], -}); -``` - -Key points: - -- Use `defineObject()` for built-in validation and better IDE support. -- The `universalIdentifier` must be unique and stable across deployments. -- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`. -- The `fields` array is optional — you can define objects without custom fields. -- You can scaffold new objects using `yarn twenty entity:add`, which guides you through naming, fields, and relationships. - - -**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields - such as `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` and `deletedAt`. - You don't need to define these in your `fields` array — only add your custom fields. - You can override default fields by defining a field with the same name in your `fields` array, - but this is not recommended. - - -### Defining fields on existing objects - -Use `defineField()` to add custom fields to existing objects — both standard objects (like `company`, `person`, `opportunity`) and custom objects defined by other apps. Each field lives in its own file and references the target object by its `universalIdentifier`. - -To reference standard objects, import `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` from `twenty-sdk`. This constant provides stable identifiers for all built-in objects and their fields: - -```typescript -// src/fields/apollo-total-funding.field.ts -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk'; - -export default defineField({ - universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e', - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, - type: FieldType.CURRENCY, - name: 'apolloTotalFunding', - label: 'Total Funding', - description: 'Total funding raised by the company', - icon: 'IconCash', -}); -``` - -Key points: - -- `objectUniversalIdentifier` tells Twenty which object to attach the field to. Use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS..universalIdentifier` for standard objects. -- Each field requires its own stable `universalIdentifier`, a `name`, `type`, `label`, and the target `objectUniversalIdentifier`. -- You can scaffold new fields using `yarn twenty entity:add` and choosing the field option. -- `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` is also exported as `STANDARD_OBJECT` for convenience — both refer to the same constant. - -Available standard objects include: `attachment`, `blocklist`, `calendarChannel`, `calendarEvent`, `calendarEventParticipant`, `company`, `connectedAccount`, `dashboard`, `favorite`, `favoriteFolder`, `message`, `messageChannel`, `messageParticipant`, `messageThread`, `note`, `noteTarget`, `opportunity`, `person`, `task`, `taskTarget`, `timelineActivity`, `workflow`, `workflowAutomatedTrigger`, `workflowRun`, `workflowVersion`, and `workspaceMember`. - -Each standard object also exposes its field identifiers. For example, to reference a specific field on a standard object in role permissions: - -```typescript -STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier -``` - -#### Relation fields on existing objects - -You can also define relation fields that link existing objects to your custom objects: - -```typescript -// src/fields/people-on-call-recording.field.ts -import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk'; -import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording'; -import { CALL_RECORDING_ON_PERSON_ID } from 'src/fields/call-recording-on-person.field'; - -export default defineField({ - universalIdentifier: '4a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d', - objectUniversalIdentifier: - CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER, - type: FieldType.RELATION, - name: 'person', - label: 'Person', - relationTargetObjectMetadataUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, - relationTargetFieldMetadataUniversalIdentifier: - CALL_RECORDING_ON_PERSON_ID, - relationType: RelationType.MANY_TO_ONE, -}); -``` - -### Application config (application-config.ts) - -Every app has a single `application-config.ts` file that describes: - -- **Who the app is**: identifiers, display name, and description. -- **How its functions run**: which role they use for permissions. -- **(Optional) variables**: key–value pairs exposed to your functions as environment variables. -- **(Optional) pre-install function**: a logic function that runs before the app is installed. -- **(Optional) post-install function**: a logic function that runs after the app is installed. - -Use `defineApplication()` to define your application configuration: - -```typescript -// src/application-config.ts -import { defineApplication } from 'twenty-sdk'; -import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'; - -export default defineApplication({ - universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', - displayName: 'My Twenty App', - description: 'My first Twenty app', - icon: 'IconWorld', - applicationVariables: { - DEFAULT_RECIPIENT_NAME: { - universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de', - description: 'Default recipient name for postcards', - value: 'Jane Doe', - isSecret: false, - }, - }, - defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, -}); -``` - -Notes: -- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs. -- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`). -- `defaultRoleUniversalIdentifier` must match the role file (see below). -- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions). - -#### Roles and permissions - -Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions. - -- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role. -- The typed client will be restricted to the permissions granted to that role. -- Follow least‑privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier. - -##### Default function role (*.role.ts) - -When you scaffold a new app, the CLI also creates a default role file. Use `defineRole()` to define roles with built-in validation: - -```typescript -// src/roles/default-role.ts -import { defineRole, PermissionFlag } from 'twenty-sdk'; - -export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = - 'b648f87b-1d26-4961-b974-0908fd991061'; - -export default defineRole({ - universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, - label: 'Default function role', - description: 'Default role for function Twenty client', - canReadAllObjectRecords: false, - canUpdateAllObjectRecords: false, - canSoftDeleteAllObjectRecords: false, - canDestroyAllObjectRecords: false, - canUpdateAllSettings: false, - canBeAssignedToAgents: false, - canBeAssignedToUsers: false, - canBeAssignedToApiKeys: false, - objectPermissions: [ - { - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: false, - canDestroyObjectRecords: false, - }, - ], - fieldPermissions: [ - { - objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050', - fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff', - canReadFieldValue: false, - canUpdateFieldValue: false, - }, - ], - permissionFlags: [PermissionFlag.APPLICATIONS], -}); -``` - -The `universalIdentifier` of this role is then referenced in `application-config.ts` as `defaultRoleUniversalIdentifier`. In other words: - -- **\*.role.ts** defines what the default function role can do. -- **application-config.ts** points to that role so your functions inherit its permissions. - -Notes: -- Start from the scaffolded role, then progressively restrict it following least‑privilege. -- Replace the `objectPermissions` and `fieldPermissions` with the objects/fields your functions need. -- `permissionFlags` control access to platform-level capabilities. Keep them minimal; add only what you need. -- See a working example in the Hello World app: [`packages/twenty-apps/hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts). - -### Logic function config and entrypoint - -Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers. - -```typescript -// src/app/createPostCard.logic-function.ts -import { defineLogicFunction } from 'twenty-sdk'; -import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk'; -import { CoreApiClient, type Person } from 'twenty-client-sdk/core'; - -const handler = async (params: RoutePayload) => { - const client = new CoreApiClient(); - const name = 'name' in params.queryStringParameters - ? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world' - : 'Hello world'; - - const result = await client.mutation({ - createPostCard: { - __args: { data: { name } }, - id: true, - name: true, - }, - }); - return result; -}; - -export default defineLogicFunction({ - universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', - name: 'create-new-post-card', - timeoutSeconds: 2, - handler, - triggers: [ - // Public HTTP route trigger '/s/post-card/create' - { - universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', - type: 'route', - path: '/post-card/create', - httpMethod: 'GET', - isAuthRequired: false, - }, - // Cron trigger (CRON pattern) - // { - // universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2', - // type: 'cron', - // pattern: '0 0 1 1 *', - // }, - // Database event trigger - // { - // universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156', - // type: 'databaseEvent', - // eventName: 'person.updated', - // updatedFields: ['name'], - // }, - ], -}); -``` - -Common trigger types: -- **route**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**: -> e.g. `path: '/post-card/create',` -> call on `/s/post-card/create` -- **cron**: Runs your function on a schedule using a CRON expression. -- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function. -> e.g. `person.updated` - -Notes: -- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions. -- You can mix multiple trigger types in a single function. - -### Pre-install functions - -A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds. - -When you scaffold a new app with `create-twenty-app`, a pre-install function is generated for you at `src/logic-functions/pre-install.ts`: - -```typescript -// src/logic-functions/pre-install.ts -import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; - -const handler = async (payload: InstallLogicFunctionPayload): Promise => { - console.log('Pre install logic function executed successfully!', payload.previousVersion); -}; - -export default definePreInstallLogicFunction({ - universalIdentifier: '', - name: 'pre-install', - description: 'Runs before installation to prepare the application.', - timeoutSeconds: 300, - handler, -}); -``` - -You can also manually execute the pre-install function at any time using the CLI: +### CLI reference ```bash filename="Terminal" -yarn twenty function:execute --preInstall +yarn twenty help # List all commands +yarn twenty server start # Start local dev server +yarn twenty remote add # Connect to a Twenty server +yarn twenty exec -n fn # Execute a logic function +yarn twenty logs -n fn # Stream function logs ``` -Key points: -- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). -- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). -- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected. -- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. -- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks. -- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`. - -### Post-install functions - -A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings. - -When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`: - -```typescript -// src/logic-functions/post-install.ts -import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'; - -const handler = async (payload: InstallLogicFunctionPayload): Promise => { - console.log('Post install logic function executed successfully!', payload.previousVersion); -}; - -export default definePostInstallLogicFunction({ - universalIdentifier: '', - name: 'post-install', - description: 'Runs after installation to set up the application.', - timeoutSeconds: 300, - handler, -}); -``` - -You can also manually execute the post-install function at any time using the CLI: - -```bash filename="Terminal" -yarn twenty function:execute --postInstall -``` - -Key points: -- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`). -- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs). -- Only one post-install function is allowed per application. The manifest build will error if more than one is detected. -- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`. -- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding. -- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`. - -### Route trigger payload - - -**Breaking change (v1.16, January 2026):** The route trigger payload format has changed. Prior to v1.16, query parameters, path parameters, and body were sent directly as the payload. Starting with v1.16, they are nested inside a structured `RoutePayload` object. - -**Before v1.16:** -```typescript -const handler = async (params) => { - const { param1, param2 } = params; // Direct access -}; -``` - -**After v1.16:** -```typescript -const handler = async (event: RoutePayload) => { - const { param1, param2 } = event.body; // Access via .body - const { queryParam } = event.queryStringParameters; - const { id } = event.pathParameters; -}; -``` - -**To migrate existing functions:** Update your handler to destructure from `event.body`, `event.queryStringParameters`, or `event.pathParameters` instead of directly from the params object. - - -When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the AWS HTTP API v2 format. Import the type from `twenty-sdk`: - -```typescript -import { defineLogicFunction, type RoutePayload } from 'twenty-sdk'; - -const handler = async (event: RoutePayload) => { - // Access request data - const { headers, queryStringParameters, pathParameters, body } = event; - - // HTTP method and path are available in requestContext - const { method, path } = event.requestContext.http; - - return { message: 'Success' }; -}; -``` - -The `RoutePayload` type has the following structure: - -| Property | Type | Description | -|----------|------|-------------| -| `headers` | `Record` | HTTP headers (only those listed in `forwardedRequestHeaders`) | -| `queryStringParameters` | `Record` | Query string parameters (multiple values joined with commas) | -| `pathParameters` | `Record` | Path parameters extracted from the route pattern (e.g., `/users/:id` → `{ id: '123' }`) | -| `body` | `object \| null` | Parsed request body (JSON) | -| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded | -| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) | -| `requestContext.http.path` | `string` | Raw request path | - -### Forwarding HTTP headers - -By default, HTTP headers from incoming requests are **not** passed to your logic function for security reasons. To access specific headers, explicitly list them in the `forwardedRequestHeaders` array: - -```typescript -export default defineLogicFunction({ - universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', - name: 'webhook-handler', - handler, - triggers: [ - { - universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6', - type: 'route', - path: '/webhook', - httpMethod: 'POST', - isAuthRequired: false, - forwardedRequestHeaders: ['x-webhook-signature', 'content-type'], - }, - ], -}); -``` - -In your handler, you can then access these headers: - -```typescript -const handler = async (event: RoutePayload) => { - const signature = event.headers['x-webhook-signature']; - const contentType = event.headers['content-type']; - - // Validate webhook signature... - return { received: true }; -}; -``` - - - Header names are normalized to lowercase. Access them using lowercase keys (for example, `event.headers['content-type']`). - - -You can create new functions in two ways: - -- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new logic function. This generates a starter file with a handler and config. -- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern. - -### Marking a logic function as a tool - -Logic functions can be exposed as **tools** for AI agents and workflows. When a function is marked as a tool, it becomes discoverable by Twenty's AI features and can be selected as a step in workflow automations. - -To mark a logic function as a tool, set `isTool: true` and provide a `toolInputSchema` describing the expected input parameters using [JSON Schema](https://json-schema.org/): - -```typescript -// src/logic-functions/enrich-company.logic-function.ts -import { defineLogicFunction } from 'twenty-sdk'; -import { CoreApiClient } from 'twenty-client-sdk/core'; - -const handler = async (params: { companyName: string; domain?: string }) => { - const client = new CoreApiClient(); - - const result = await client.mutation({ - createTask: { - __args: { - data: { - title: `Enrich data for ${params.companyName}`, - body: `Domain: ${params.domain ?? 'unknown'}`, - }, - }, - id: true, - }, - }); - - return { taskId: result.createTask.id }; -}; - -export default defineLogicFunction({ - universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', - name: 'enrich-company', - description: 'Enrich a company record with external data', - timeoutSeconds: 10, - handler, - isTool: true, - toolInputSchema: { - type: 'object', - properties: { - companyName: { - type: 'string', - description: 'The name of the company to enrich', - }, - domain: { - type: 'string', - description: 'The company website domain (optional)', - }, - }, - required: ['companyName'], - }, -}); -``` - -Key points: - -- **`isTool`** (`boolean`, default: `false`): When set to `true`, the function is registered as a tool and becomes available to AI agents and workflow automations. -- **`toolInputSchema`** (`object`, optional): A JSON Schema object that describes the parameters your function accepts. AI agents use this schema to understand what inputs the tool expects and to validate calls. If omitted, the schema defaults to `{ type: 'object', properties: {} }` (no parameters). -- Functions with `isTool: false` (or unset) are **not** exposed as tools. They can still be executed directly or called by other functions, but will not appear in tool discovery. -- **Tool naming**: When exposed as a tool, the function name is automatically normalized to `logic_function_` (lowercased, non-alphanumeric characters replaced with underscores). For example, `enrich-company` becomes `logic_function_enrich_company`. -- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events (cron, database events, routes) at the same time. - - -**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. - - -### Front components - -Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation: - -```typescript -// src/front-components/my-widget.tsx -import { defineFrontComponent } from 'twenty-sdk'; - -const MyWidget = () => { - return ( -
-

My Custom Widget

-

This is a custom front component for Twenty.

-
- ); -}; - -export default defineFrontComponent({ - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - name: 'my-widget', - description: 'A custom widget component', - component: MyWidget, -}); -``` - -Key points: -- Front components are React components that render in isolated contexts within Twenty. -- The `component` field references your React component. -- Components are built and synced automatically during `yarn twenty dev`. - -You can create new front components in two ways: - -- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component. -- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern. - -#### Where front components can be used - -Front components can render in two locations within Twenty: - -- **Side panel** — Non-headless front components open in the right-hand side panel. This is the default behavior when a front component is triggered from the command menu. -- **Widgets (dashboards and record pages)** — Front components can be embedded as widgets inside page layouts. When configuring a dashboard or a record page layout, users can add a front component widget. - -#### 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** — 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. - -```typescript -export default defineFrontComponent({ - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - name: 'my-action', - description: 'Runs an action without opening the side panel', - component: MyAction, - isHeadless: true, - command: { - universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', - label: 'Run my action', - }, -}); -``` - -#### Adding command menu items - -To make a front component appear as an item in Twenty's command menu, add the `command` property to `defineFrontComponent()`. When users open the command menu (Cmd+K / Ctrl+K), the item shows up and triggers the front component on click. - -The `command` object accepts the following fields: - -| Field | Type | Description | -|-------|------|-------------| -| `universalIdentifier` | `string` (required) | Unique ID for the command menu item | -| `label` | `string` (required) | Display label shown in the command menu | -| `icon` | `string` (optional) | Icon name (e.g., `'IconSparkles'`) | -| `isPinned` | `boolean` (optional) | Whether the command is pinned at the top of the menu | -| `availabilityType` | `'GLOBAL' \| 'RECORD_SELECTION'` (optional) | `GLOBAL` shows the command everywhere; `RECORD_SELECTION` shows it only in record contexts | -| `availabilityObjectUniversalIdentifier` | `string` (optional) | Restrict the command to a specific object type (e.g., Person) | - -Here is an example from the call-recording app that adds a command scoped to Person records: - -```typescript -import { defineFrontComponent } from 'twenty-sdk'; - -export default defineFrontComponent({ - universalIdentifier: 'c3d4e5f6-a7b8-9012-cdef-123456789012', - name: 'Summarize Person Call Recordings', - description: 'Generates a summary of call recordings for a person', - component: SummarizePersonRecordings, - command: { - universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-234567890123', - label: 'Summarize call recordings', - icon: 'IconSparkles', - isPinned: false, - availabilityType: 'RECORD_SELECTION', - availabilityObjectUniversalIdentifier: - '20202020-e674-48e5-a542-72570eee7213', - }, -}); -``` - -When the command is synced, it appears in the command menu. If the front component is non-headless the side panel opens with the component rendered inside. If it is headless the component mounts in the background and executes its logic. - -#### SDK Command components - -The `twenty-sdk` package provides four Command helper components designed for headless front components. Each component executes an action on mount, handles errors by showing a snackbar notification, and automatically unmounts the front component when done. - -Import them from `twenty-sdk/command`: - -- **`Command`** — Runs an async callback via the `execute` prop. -- **`CommandLink`** — Navigates to an app path. Props: `to`, `params`, `queryParams`, `options`. -- **`CommandModal`** — Opens a confirmation modal. If the user confirms, executes the `execute` callback. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`. -- **`CommandOpenSidePanelPage`** — Opens a specific side panel page. Props: `page`, `pageTitle`, `pageIcon`. - -Here is a full example of a headless front component using `Command` to run an action from the command menu: - -```typescript -// src/front-components/run-action.tsx -import { defineFrontComponent } from 'twenty-sdk'; -import { Command } from 'twenty-sdk/command'; -import { CoreApiClient } from 'twenty-sdk/clients'; - -const RunAction = () => { - const execute = async () => { - const client = new CoreApiClient(); - - await client.mutation({ - createTask: { - __args: { data: { title: 'Created by my app' } }, - id: true, - }, - }); - }; - - return ; -}; - -export default defineFrontComponent({ - universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234', - name: 'run-action', - description: 'Creates a task from the command menu', - component: RunAction, - isHeadless: true, - command: { - universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345', - label: 'Run my action', - icon: 'IconPlayerPlay', - }, -}); -``` - -And an example using `CommandModal` to ask for confirmation before executing: - -```typescript -// src/front-components/delete-draft.tsx -import { defineFrontComponent } from 'twenty-sdk'; -import { CommandModal } from 'twenty-sdk/command'; - -const DeleteDraft = () => { - const execute = async () => { - // perform the deletion - }; - - return ( - - ); -}; - -export default defineFrontComponent({ - universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456', - name: 'delete-draft', - description: 'Deletes a draft with confirmation', - component: DeleteDraft, - isHeadless: true, - command: { - universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567', - label: 'Delete draft', - icon: 'IconTrash', - }, -}); -``` - -#### Execution context - -Every front component receives an execution context that provides information about where and how it is running. Access context values using hooks from `twenty-sdk`: - -| Hook | Return type | Description | -|------|-------------|-------------| -| `useFrontComponentId()` | `string` | The unique ID of the current front component instance | -| `useRecordId()` | `string \| null` | The ID of the current record, when the component runs in a record context (e.g., a record page widget or a command scoped to a record). Returns `null` otherwise. | -| `useUserId()` | `string \| null` | The ID of the current user | - -```typescript -import { useRecordId, useUserId } from 'twenty-sdk'; - -const MyWidget = () => { - const recordId = useRecordId(); - const userId = useUserId(); - - return ( -
-

Record: {recordId ?? 'none'}

-

User: {userId ?? 'anonymous'}

-
- ); -}; -``` - -The context is reactive — if the surrounding record changes, hooks automatically return the updated values. - -#### Host API functions - -Front components run in an isolated sandbox but can interact with Twenty's UI through a set of functions provided by the host. Import them directly from `twenty-sdk`: - -```typescript -import { - navigate, - closeSidePanel, - enqueueSnackbar, - unmountFrontComponent, - openSidePanelPage, - openCommandConfirmationModal, -} from 'twenty-sdk'; -``` - -| Function | Signature | Description | -|----------|-----------|-------------| -| `navigate` | `(to, params?, queryParams?, options?) => Promise` | Navigate to a typed app path within Twenty | -| `closeSidePanel` | `() => Promise` | Close the side panel | -| `enqueueSnackbar` | `(params) => Promise` | Show a snackbar notification. Params: `message`, `variant` (`'error'`, `'success'`, `'info'`, `'warning'`), optional `duration`, `detailedMessage`, `dedupeKey` | -| `unmountFrontComponent` | `() => Promise` | Unmount the current front component (used by headless components to clean up after execution) | -| `openSidePanelPage` | `(params) => Promise` | Open a page in the side panel. Params: `page`, `pageTitle`, `pageIcon`, `shouldResetSearchState` | -| `openCommandConfirmationModal` | `(params) => Promise<'confirm' \| 'cancel'>` | Show a confirmation modal and wait for the user's response. Params: `title`, `subtitle`, `confirmButtonText`, `confirmButtonAccent` (`'default'`, `'blue'`, `'danger'`) | - -Here is an example that uses the host API to show a snackbar and close the side panel after an action completes: - -```typescript -import { defineFrontComponent, useRecordId } from 'twenty-sdk'; -import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk'; -import { CoreApiClient } from 'twenty-sdk/clients'; - -const ArchiveRecord = () => { - const recordId = useRecordId(); - - const handleArchive = async () => { - const client = new CoreApiClient(); - - await client.mutation({ - updateTask: { - __args: { id: recordId, data: { status: 'ARCHIVED' } }, - id: true, - }, - }); - - await enqueueSnackbar({ - message: 'Record archived', - variant: 'success', - }); - - await closeSidePanel(); - }; - - return ( -
-

Archive this record?

- -
- ); -}; - -export default defineFrontComponent({ - universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678', - name: 'archive-record', - description: 'Archives the current record', - component: ArchiveRecord, -}); -``` - -### Skills - -Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation: - -```typescript -// src/skills/example-skill.ts -import { defineSkill } from 'twenty-sdk'; - -export default defineSkill({ - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - name: 'sales-outreach', - label: 'Sales Outreach', - description: 'Guides the AI agent through a structured sales outreach process', - icon: 'IconBrain', - content: `You are a sales outreach assistant. When reaching out to a prospect: -1. Research the company and recent news -2. Identify the prospect's role and likely pain points -3. Draft a personalized message referencing specific details -4. Keep the tone professional but conversational`, -}); -``` - -Key points: -- `name` is a unique identifier string for the skill (kebab-case recommended). -- `label` is the human-readable display name shown in the UI. -- `content` contains the skill instructions — this is the text the AI agent uses. -- `icon` (optional) sets the icon displayed in the UI. -- `description` (optional) provides additional context about the skill's purpose. - -You can create new skills in two ways: - -- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill. -- **Manual**: Create a new file and use `defineSkill()`, following the same pattern. - -### Agents - -Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation: - -```typescript -// src/agents/example-agent.ts -import { defineAgent } from 'twenty-sdk'; - -export default defineAgent({ - universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - name: 'sales-assistant', - label: 'Sales Assistant', - description: 'An AI agent that helps with sales tasks', - icon: 'IconRobot', - prompt: `You are a sales assistant. Help users with: -1. Researching prospects and companies -2. Drafting personalized outreach messages -3. Tracking follow-ups and next steps -4. Analyzing deal pipeline and suggesting actions`, -}); -``` - -Key points: -- `name` is a unique identifier string for the agent (kebab-case recommended). -- `label` is the human-readable display name shown in the UI. -- `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior. -- `icon` (optional) sets the icon displayed in the UI. -- `description` (optional) provides additional context about the agent's purpose. - -You can create new agents in two ways: - -- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent. -- **Manual**: Create a new file and use `defineAgent()`, following the same pattern. - -### Generated typed clients - -Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_modules/twenty-sdk/clients` based on your workspace schema: - -- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data -- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads - -```typescript -import { CoreApiClient } from 'twenty-client-sdk/core'; -import { MetadataApiClient } from 'twenty-client-sdk/metadata'; - -const client = new CoreApiClient(); -const { me } = await client.query({ me: { id: true, displayName: true } }); - -const metadataClient = new MetadataApiClient(); -const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } }); -``` - -`CoreApiClient` is re-generated automatically by `yarn twenty dev` whenever your objects or fields change. `MetadataApiClient` ships pre-built with the SDK. - -#### Runtime credentials in logic functions - -When your function runs on Twenty, the platform injects credentials as environment variables before your code executes: - -- `TWENTY_API_URL`: Base URL of the Twenty API your app targets. -- `TWENTY_API_KEY`: Short‑lived key scoped to your application's default function role. - -Notes: -- You do not need to pass URL or API key to the generated client. It reads `TWENTY_API_URL` and `TWENTY_API_KEY` from process.env at runtime. -- The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application. -- Applications can define roles to follow least‑privilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier. - -#### Uploading files - -The `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood. - -```typescript -import { MetadataApiClient } from 'twenty-client-sdk/metadata'; -import * as fs from 'fs'; - -const metadataClient = new MetadataApiClient(); - -const fileBuffer = fs.readFileSync('./invoice.pdf'); - -const uploadedFile = await metadataClient.uploadFile( - fileBuffer, // file contents as a Buffer - 'invoice.pdf', // filename - 'application/pdf', // MIME type (defaults to 'application/octet-stream') - '58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier -); - -console.log(uploadedFile); -// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' } -``` - -The method signature: - -```typescript -uploadFile( - fileBuffer: Buffer, - filename: string, - contentType: string, - fieldMetadataUniversalIdentifier: string, -): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }> -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `fileBuffer` | `Buffer` | The raw file contents | -| `filename` | `string` | The name of the file (used for storage and display) | -| `contentType` | `string` | MIME type of the file (defaults to `application/octet-stream` if omitted) | -| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object | - -Key points: -- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint. -- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else. -- The returned `url` is a signed URL you can use to access the uploaded file. - -### Hello World example - -Explore a minimal, end-to-end example that demonstrates objects, logic functions, front components, and multiple triggers [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world): - -## Building your app - -Once you've developed your app with `app:dev`, use `app:build` to compile it into a distributable package. - -```bash filename="Terminal" -# Build the app (output goes to .twenty/output/) -yarn twenty build - -# Build and create a tarball (.tgz) for distribution -yarn twenty build --tarball -``` - -The build process: - -1. **Parses and validates the manifest** — reads all `defineX()` entities from your source files and validates the manifest structure. -2. **Compiles logic functions and front components** — bundles TypeScript sources into ESM `.mjs` files using esbuild. -3. **Generates checksums** — computes MD5 hashes for each built file, stored in the manifest as `builtHandlerChecksum` / `builtComponentChecksum`. -4. **Generates the typed API client** — introspects the GraphQL schema and generates typed `CoreApiClient` and `MetadataApiClient` clients. -5. **Runs a TypeScript type check** — runs `tsc --noEmit` to catch type errors before publishing. -6. **Rebuilds with the generated client** — performs a second compilation pass so the generated client types are included. -7. **Optionally creates a tarball** — if `--tarball` is passed, runs `npm pack` to create a `.tgz` file ready for distribution. - -The build output in `.twenty/output/` contains: - -```text -.twenty/output/ -├── manifest.json # Manifest with checksums for all built files -├── package.json # Copied from app root -├── yarn.lock # Copied from app root -├── src/ -│ ├── logic-functions/ # Compiled .mjs logic function files -│ └── front-components/ # Compiled .mjs front component files -├── public/ # Static assets (if any) -└── my-app-1.0.0.tgz # Only with --tarball flag -``` - -| Option | Description | -|--------|-------------| -| `[appPath]` | Path to the app directory (defaults to current directory) | -| `--tarball` | Also pack the output into a `.tgz` tarball | - -## Publishing your app - -Use `app:publish` to distribute your app — either to the npm registry or directly to a Twenty server. - -### Publish to npm (default) - -```bash filename="Terminal" -# Publish to npm (requires npm login) -yarn twenty publish - -# Publish with a dist-tag (e.g. beta, next) -yarn twenty publish --tag beta -``` - -This builds the app and runs `npm publish` from the `.twenty/output/` directory. The published package can then be installed from the Twenty marketplace by any workspace. - -### Publish to a Twenty server - -```bash filename="Terminal" -# Publish directly to a Twenty server -yarn twenty publish --server https://app.twenty.com -``` - -This builds the app with a tarball, uploads it to the server via the `uploadAppTarball` GraphQL mutation, and triggers installation in one step. This is useful for private deployments or testing against a specific server. - -| Option | Description | -|--------|-------------| -| `[appPath]` | Path to the app directory (defaults to current directory) | -| `--server ` | Publish to a Twenty server instead of npm | -| `--token ` | Authentication token for the target server | -| `--tag ` | npm dist-tag (e.g. `beta`, `next`) — only for npm publish | - -## Application registration - -Before an app can be installed in a workspace, it must be **registered**. A registration is a metadata record that describes where the app comes from and how to authenticate it. This is handled automatically by the CLI in most cases. - -### Source types - -Each registration has a **source type** that determines how the app's files are resolved during installation: - -| Source type | How files are resolved | Typical use case | -|-------------|----------------------|------------------| -| `LOCAL` | Files are synced in real-time by the CLI watcher — installation is skipped | Development with `app:dev` | -| `NPM` | Fetched from the npm registry via the `sourcePackage` field | Published apps on npm | -| `TARBALL` | Extracted from an uploaded `.tgz` file stored on the server | Private apps published with `--server` | - -### How registration happens - -- **`app:dev`** — automatically creates a `LOCAL` registration the first time you run dev mode against a workspace. -- **`app:publish --server`** — uploads a tarball and creates (or updates) a `TARBALL` registration, then installs the app. -- **npm marketplace** — `NPM` registrations are created when apps are synced from the npm registry into the Twenty marketplace catalog. -- **GraphQL API** — you can also create registrations programmatically via the `createApplicationRegistration` mutation. - -### Registration vs installation - -**Registration** and **installation** are separate concepts: - -- A **registration** (`ApplicationRegistration`) is a global metadata record describing the app: its name, source type, OAuth credentials, and marketplace listing status. It exists independently of any workspace. -- An **installation** (`Application`) is a per-workspace instance. When a user installs an app, Twenty resolves the package from the registration's source, writes the built files to storage, and synchronizes the manifest (creating objects, fields, logic functions, etc.) in that workspace. - -One registration can be installed in many workspaces. Each workspace gets its own copy of the app's files and data model. - -### OAuth credentials - -Each registration includes OAuth credentials (`oAuthClientId` and `oAuthClientSecret`) generated at creation time. These are used by the app to authenticate API requests on behalf of users. The client secret is returned **once** at creation — store it securely. You can rotate it later via the `rotateApplicationRegistrationClientSecret` mutation. - -## Manual setup (without the scaffolder) - -While we recommend using `create-twenty-app` for the best getting-started experience, you can also set up a project manually. Do not install the CLI globally. Instead, add `twenty-sdk` as a local dependency and wire a single script in your package.json: - -```bash filename="Terminal" -yarn add -D twenty-sdk -``` - -Then add a `twenty` script: - -```json filename="package.json" -{ - "scripts": { - "twenty": "twenty" - } -} -``` - -Now you can run all commands via `yarn twenty `, e.g. `yarn twenty dev`, `yarn twenty help`, etc. - -## Troubleshooting - -- Authentication errors: run `yarn twenty auth:login` and ensure your API key has the required permissions. -- Cannot connect to server: verify the API URL and that the Twenty server is reachable. -- Types or client missing/outdated: restart `yarn twenty dev` — it auto-generates the typed client. -- Dev mode not syncing: ensure `yarn twenty dev` is running and that changes are not ignored by your environment. - -Discord Help Channel: https://discord.com/channels/1130383047699738754/1130386664812982322 +See the [Getting Started](/developers/extend/apps/getting-started) guide for the full CLI reference. diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-1.png b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-1.png new file mode 100644 index 0000000000..2eb251185f Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-1.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-2.png b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-2.png new file mode 100644 index 0000000000..8d31868927 Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-2.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-3.png b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-3.png new file mode 100644 index 0000000000..6ff4232851 Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-3.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-4.png b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-4.png new file mode 100644 index 0000000000..8792d6a8fe Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/app-in-ui-4.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/authorize.png b/packages/twenty-docs/images/docs/developers/extends/apps/authorize.png new file mode 100644 index 0000000000..e375ca8707 Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/authorize.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/dev.jpg b/packages/twenty-docs/images/docs/developers/extends/apps/dev.jpg new file mode 100644 index 0000000000..94eb48c19c Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/dev.jpg differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/login.png b/packages/twenty-docs/images/docs/developers/extends/apps/login.png new file mode 100644 index 0000000000..941bb8d1aa Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/login.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/quick-action.png b/packages/twenty-docs/images/docs/developers/extends/apps/quick-action.png new file mode 100644 index 0000000000..032f9d25dd Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/quick-action.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/scaffolded.png b/packages/twenty-docs/images/docs/developers/extends/apps/scaffolded.png new file mode 100644 index 0000000000..4dfb9a8f1b Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/scaffolded.png differ diff --git a/packages/twenty-docs/images/docs/developers/extends/apps/start-instance.png b/packages/twenty-docs/images/docs/developers/extends/apps/start-instance.png new file mode 100644 index 0000000000..f8ce8f6b3c Binary files /dev/null and b/packages/twenty-docs/images/docs/developers/extends/apps/start-instance.png differ diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 8fe193cc6e..1bf794a970 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -14,12 +14,9 @@ A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com). -- Typed GraphQL clients: `CoreApiClient` (auto-generated per app for workspace data) and `MetadataApiClient` (pre-built with the SDK for workspace configuration & file uploads) -- Built‑in CLI for auth, dev mode (watch & sync), uninstall, and function management +## Quick start -## Getting Started - -The recommended way to start building a Twenty app is with [**create-twenty-app**](https://www.npmjs.com/package/create-twenty-app), which scaffolds a project with everything preconfigured: +The recommended way to start is with [create-twenty-app](https://www.npmjs.com/package/create-twenty-app): ```bash npx create-twenty-app@latest my-app @@ -27,330 +24,47 @@ cd my-app yarn twenty dev ``` -See the [create-twenty-app README](https://www.npmjs.com/package/create-twenty-app) or the [full documentation](https://docs.twenty.com/developers/extend/capabilities/apps) for details. +## Documentation -## Prerequisites +Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started)**: -- Node.js 24+ (recommended) and Yarn 4 -- Docker (for the local Twenty dev server) or a remote Twenty workspace +- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started) — scaffolding, local server, authentication, dev mode +- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing, CLI reference +- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace -## Manual Installation +## Manual installation -If you're adding `twenty-sdk` to an existing project instead of using `create-twenty-app`: +If you are adding `twenty-sdk` to an existing project instead of using `create-twenty-app`: ```bash -npm install twenty-sdk -# or -yarn add twenty-sdk +yarn add twenty-sdk twenty-client-sdk ``` -## Usage - -``` -Usage: twenty [options] [command] - -CLI for Twenty application development - -Options: - -V, --version output the version number - -r, --remote Use a specific remote (overrides the default set by remote switch) - -h, --help display help for command - -Commands: - dev [appPath] Watch and sync local application changes - build [appPath] Build, sync, and generate API client into .twenty/output/ - deploy [appPath] Build and deploy to a Twenty server - publish [appPath] Build and publish to npm - typecheck [appPath] Run TypeScript type checking on the application - uninstall [appPath] Uninstall application from Twenty - remote Manage remote Twenty servers - server Manage a local Twenty server instance - add [entityType] Add a new entity to your application - exec [appPath] Execute a logic function with a JSON payload - logs [appPath] Watch application function logs - help [command] display help for command -``` - -In a project created with `create-twenty-app` (recommended), use `yarn twenty ` instead of calling `twenty` directly. For example: `yarn twenty help`, `yarn twenty dev`, etc. - -## Global Options - -- `--remote ` (or `-r `): Use a specific remote configuration. Defaults to `local`. See Configuration for details. - -## Commands - -### Server - -Manage a local Twenty dev server (all-in-one Docker image on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000). - -- `twenty server start` — Start the local server (pulls image if needed). Automatically configures the `local` remote. - - Options: - - `-p, --port `: HTTP port (default: `2020`). -- `twenty server stop` — Stop the local server. -- `twenty server logs` — Stream server logs. - - Options: - - `-n, --lines `: Number of initial lines to show (default: `50`). -- `twenty server status` — Show server status (running/stopped/healthy). -- `twenty server reset` — Delete all data and start fresh. - -The server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`). - -Examples: - -```bash -# Start the local server -twenty server start - -# Check if it's ready -twenty server status - -# Follow logs during first startup -twenty server logs - -# Stop the server (data is preserved) -twenty server stop - -# Wipe everything and start over -twenty server reset -``` - -### Remote - -Manage remote server connections and authentication. - -- `twenty remote add [nameOrUrl]` — Add a new remote or re-authenticate an existing one. - - - Options: - - `--token `: API key for non-interactive auth. - - `--url `: Server URL (alternative to positional arg). - - `--as `: Name for this remote (otherwise derived from URL hostname). - - Behavior: If `nameOrUrl` matches an existing remote name, re-authenticates it. Otherwise, creates a new remote and authenticates via OAuth (with API key fallback). - -- `twenty remote remove ` — Remove a remote and its credentials. - -- `twenty remote list` — List all configured remotes with their auth status and URLs. - -- `twenty remote switch [name]` — Set the default remote. - - - If omitted, shows an interactive selection. - -- `twenty remote status` — Print the current remote name, server URL, and auth status. - -Examples: - -```bash -# Add a remote interactively (recommended) -twenty remote add - -# Provide values in flags (non-interactive, for CI) -twenty remote add https://api.twenty.com --token $TWENTY_API_KEY - -# Name a remote explicitly -twenty remote add https://api.twenty.com --as production - -# Re-authenticate an existing remote by name -twenty remote add production - -# Check status -twenty remote status - -# List all configured remotes -twenty remote list - -# Switch default remote -twenty remote switch production - -# Remove a remote -twenty remote remove production -``` - -### App - -Application development commands. - -- `twenty dev [appPath]` — Start development mode: watch and sync local application changes. - - - Behavior: Builds your application (functions and front components), computes the manifest, syncs everything to your remote, then watches the directory for changes and re-syncs automatically. Displays an interactive UI showing build and sync status in real time. Press Ctrl+C to stop. - -- `twenty build [appPath]` — Build the application, sync to the server, generate the typed API client, then rebuild with the real client. - - - Options: - - `--tarball`: Also pack the output into a `.tgz` tarball. - -- `twenty publish [appPath]` — Build and publish the application to npm. - - - Behavior: Builds the application and runs `npm publish` on the output directory. - - Options: - - `--tag `: npm dist-tag (e.g. `beta`, `next`). - -- `twenty deploy [appPath]` — Build and deploy the application to a Twenty server. - - - Behavior: Builds the tarball, uploads it to the server, and installs the application. - - Options: - - `--server `: Target Twenty server URL. - - `--token `: Auth token for the server. - -- `twenty typecheck [appPath]` — Run TypeScript type checking on the application (runs `tsc --noEmit`). Exits with code 1 if type errors are found. - -- `twenty uninstall [appPath]` — Uninstall the application from the current remote. - -### Entity - -- `twenty add [entityType]` — Add a new entity to your application. - - Arguments: - - `entityType`: one of `object`, `field`, `function`, `front-component`, `role`, `view`, `navigation-menu-item`, or `skill`. If omitted, an interactive prompt is shown. - - Options: - - `--path `: The path where the entity file should be created (relative to the current directory). - - Behavior: - - `object`: prompts for singular/plural names and labels, then creates a `*.object.ts` definition file. - - `field`: prompts for name, label, type, and target object, then creates a `*.field.ts` definition file. - - `function`: prompts for a name and scaffolds a `*.function.ts` logic function file. - - `front-component`: prompts for a name and scaffolds a `*.front-component.tsx` file. - - `role`: prompts for a name and scaffolds a `*.role.ts` role definition file. - - `view`: prompts for a name and target object, then creates a `*.view.ts` definition file. - - `navigation-menu-item`: prompts for a name and scaffolds a `*.navigation-menu-item.ts` file. - - `skill`: prompts for a name and scaffolds a `*.skill.ts` skill definition file. - -### Function - -- `twenty logs [appPath]` — Stream application function logs. - - - Options: - - `-u, --functionUniversalIdentifier `: Only show logs for a specific function universal ID. - - `-n, --functionName `: Only show logs for a specific function name. - -- `twenty exec [appPath]` — Execute a logic function with a JSON payload. - - Options: - - `--preInstall`: Execute the pre-install logic function defined in the application manifest (required if `--postInstall`, `-n`, and `-u` not provided). - - `--postInstall`: Execute the post-install logic function defined in the application manifest (required if `--preInstall`, `-n`, and `-u` not provided). - - `-n, --functionName `: Name of the function to execute (required if `--postInstall` and `-u` not provided). - - `-u, --functionUniversalIdentifier `: Universal ID of the function to execute (required if `--postInstall` and `-n` not provided). - - `-p, --payload `: JSON payload to send to the function (default: `{}`). - -Examples: - -```bash -# Start dev mode (watch, build, and sync) -twenty dev - -# Start dev mode with a custom remote -twenty dev --remote my-custom-remote - -# Type check the application -twenty typecheck - -# Add a new entity interactively -twenty add - -# Add a new function -twenty add function - -# Add a new front component -twenty add front-component - -# Add a new view -twenty add view - -# Add a new navigation menu item -twenty add navigation-menu-item - -# Add a new skill -twenty add skill - -# Build the app (output in .twenty/output/) -twenty build - -# Build and create a tarball -twenty build --tarball - -# Publish to npm -twenty publish - -# Publish with a dist-tag -twenty publish --tag beta - -# Deploy directly to a Twenty server (builds, uploads, and installs) -twenty deploy --server https://app.twenty.com - -# Uninstall the app from the remote -twenty uninstall - -# Watch all function logs -twenty logs - -# Watch logs for a specific function by name -twenty logs -n my-function - -# Execute a function by name (with empty payload) -twenty exec -n my-function - -# Execute a function with a JSON payload -twenty exec -n my-function -p '{"name": "test"}' - -# Execute a function by universal identifier -twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}' - -# Execute the pre-install function -twenty exec --preInstall - -# Execute the post-install function -twenty exec --postInstall -``` - -## Configuration - -The CLI stores configuration per user in a JSON file: - -- Location: `~/.twenty/config.json` -- Structure: Remotes keyed by name. The active remote is selected with `--remote ` or by the `defaultRemote` setting. - -Example configuration file: +Then add a `twenty` script to your `package.json`: ```json { - "defaultRemote": "production", - "remotes": { - "local": { - "apiUrl": "http://localhost:2020", - "apiKey": "" - }, - "production": { - "apiUrl": "https://api.twenty.com", - "accessToken": "", - "refreshToken": "", - "oauthClientId": "" - } + "scripts": { + "twenty": "twenty" } } ``` -Notes: +Run `yarn twenty help` to see all available commands. -- If a remote is missing, `apiUrl` defaults to `http://localhost:2020`. -- `twenty remote add` writes credentials for the active remote (OAuth tokens or API key). -- `twenty remote add --as my-remote` saves under a custom name. -- `twenty remote switch` sets the `defaultRemote` field, used when `-r` is not specified. -- `twenty remote list` shows all configured remotes and their authentication status. +## Configuration -## How to use a local Twenty instance - -If you're already running a local Twenty instance, you can connect to it instead of using Docker: - -```bash -twenty remote add http://localhost:3000 --as local -``` +The CLI stores credentials per remote in `~/.twenty/config.json`. Run `yarn twenty remote add` to configure a remote, or `yarn twenty remote list` to see existing ones. ## Troubleshooting -- Auth errors: run `twenty remote add` again (or add a new remote) and ensure the API key has the required permissions. -- Typings out of date: restart `twenty dev` to refresh the client and types. -- Not seeing changes in dev: make sure dev mode is running (`twenty dev`). +- Auth errors: run `yarn twenty remote add` to re-authenticate. +- Typings out of date: restart `yarn twenty dev` to refresh the client and types. +- Not seeing changes in dev: make sure dev mode is running (`yarn twenty dev`). ## Contributing -### Development Setup - -To contribute to the twenty-sdk package, clone the repository and install dependencies: +### Development setup ```bash git clone https://github.com/twentyhq/twenty.git @@ -358,37 +72,22 @@ cd twenty yarn install ``` -### Development Mode - -Run the SDK build in watch mode to automatically rebuild on file changes: +### Development mode ```bash npx nx run twenty-sdk:dev ``` -This will watch for changes and rebuild the `dist` folder automatically. - -### Production Build - -Build the SDK for production: +### Production build ```bash npx nx run twenty-sdk:build ``` -### Running the CLI Locally - -After building, you can run the CLI directly: +### Running the CLI locally ```bash npx nx run twenty-sdk:start -- -# Example: npx nx run twenty-sdk:start -- remote status -``` - -Or run the built CLI directly: - -```bash -node packages/twenty-sdk/dist/cli.cjs ``` ### Resources diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 973be2a117..8807ba7c0c 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-sdk", - "version": "0.8.0-canary.7", + "version": "0.8.0-canary.8", "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/sdk/index.d.ts", diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index 1786b1d490..66c1ca58ee 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -134,6 +134,7 @@ export const registerCommands = (program: Command): void => { program .command('exec [appPath]') + .option('--preInstall', 'Execute pre-install logic function if defined') .option('--postInstall', 'Execute post-install logic function if defined') .option( '-p, --payload ', @@ -153,6 +154,7 @@ export const registerCommands = (program: Command): void => { async ( appPath?: string, options?: { + preInstall?: boolean; postInstall?: boolean; payload?: string; functionUniversalIdentifier?: string; @@ -160,13 +162,14 @@ export const registerCommands = (program: Command): void => { }, ) => { if ( + !options?.preInstall && !options?.postInstall && !options?.functionUniversalIdentifier && !options?.functionName ) { console.error( chalk.red( - 'Error: Either --postInstall or --functionName (-n) or --functionUniversalIdentifier (-u) is required.', + 'Error: Either --preInstall, --postInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.', ), ); process.exit(1); diff --git a/packages/twenty-sdk/src/cli/commands/exec.ts b/packages/twenty-sdk/src/cli/commands/exec.ts index a68562bb70..54afde5231 100644 --- a/packages/twenty-sdk/src/cli/commands/exec.ts +++ b/packages/twenty-sdk/src/cli/commands/exec.ts @@ -7,12 +7,14 @@ import { isDefined } from 'twenty-shared/utils'; export class LogicFunctionExecuteCommand { async execute({ appPath = CURRENT_EXECUTION_DIRECTORY, + preInstall = false, postInstall = false, functionUniversalIdentifier, functionName, payload = '{}', }: { appPath?: string; + preInstall?: boolean; postInstall?: boolean; functionUniversalIdentifier?: string; functionName?: string; @@ -28,18 +30,22 @@ export class LogicFunctionExecuteCommand { process.exit(1); } - const identifier = postInstall - ? 'post install' - : (functionUniversalIdentifier ?? functionName); + const identifier = preInstall + ? 'pre install' + : postInstall + ? 'post install' + : (functionUniversalIdentifier ?? functionName); console.log(chalk.blue(`🚀 Executing function "${identifier}"...`)); console.log(chalk.gray(` Payload: ${JSON.stringify(parsedPayload)}\n`)); - const executeOptions = postInstall - ? { appPath, postInstall: true as const, payload: parsedPayload } - : functionUniversalIdentifier - ? { appPath, functionUniversalIdentifier, payload: parsedPayload } - : { appPath, functionName: functionName!, payload: parsedPayload }; + const executeOptions = preInstall + ? { appPath, preInstall: true as const, payload: parsedPayload } + : postInstall + ? { appPath, postInstall: true as const, payload: parsedPayload } + : functionUniversalIdentifier + ? { appPath, functionUniversalIdentifier, payload: parsedPayload } + : { appPath, functionName: functionName!, payload: parsedPayload }; const result = await functionExecute(executeOptions); diff --git a/packages/twenty-sdk/src/cli/commands/remote.ts b/packages/twenty-sdk/src/cli/commands/remote.ts index 2646828982..2773f74ab7 100644 --- a/packages/twenty-sdk/src/cli/commands/remote.ts +++ b/packages/twenty-sdk/src/cli/commands/remote.ts @@ -17,9 +17,9 @@ const deriveRemoteName = (url: string): string => { } }; -const authenticate = async (apiUrl: string, token?: string): Promise => { - const result = token - ? await authLogin({ apiKey: token, apiUrl }) +const authenticate = async (apiUrl: string, apiKey?: string): Promise => { + const result = apiKey + ? await authLogin({ apiKey, apiUrl }) : await runOAuthWithApiKeyFallback(apiUrl); if (!result.success) { @@ -66,40 +66,32 @@ export const registerRemoteCommands = (program: Command): void => { .description('Manage remote Twenty servers'); remote - .command('add [nameOrUrl]') + .command('add') .description('Add a new remote or re-authenticate an existing one') .option('--as ', 'Name for this remote') - .option('--token ', 'API key for non-interactive auth') - .option('--url ', 'Server URL (alternative to positional arg)') + .option('--api-key ', 'API key for non-interactive auth') + .option('--api-url ', 'Server URL') .option('--local', 'Connect to a local Twenty server (auto-detect)') .action( - async ( - nameOrUrl: string | undefined, - options: { - as?: string; - token?: string; - url?: string; - local?: boolean; - }, - ) => { + async (options: { + as?: string; + apiKey?: string; + apiUrl?: string; + local?: boolean; + }) => { const configService = new ConfigService(); const existingRemotes = await configService.getRemotes(); - // Re-authenticate an existing remote by name - const isExistingRemote = - nameOrUrl !== undefined && existingRemotes.includes(nameOrUrl); + if (options.as !== undefined && existingRemotes.includes(options.as)) { + const config = await configService.getConfigForRemote(options.as); - if (isExistingRemote) { - const config = await configService.getConfigForRemote(nameOrUrl); - - ConfigService.setActiveRemote(nameOrUrl); - await authenticate(config.apiUrl, options.token); + ConfigService.setActiveRemote(options.as); + await authenticate(config.apiUrl, options.apiKey); return; } - // Resolve the URL — from args, flags, auto-detect, or interactive prompt - let apiUrl = nameOrUrl ?? options.url; + let apiUrl = options.apiUrl; if (!apiUrl) { const detectedUrl = await detectLocalServer(); @@ -115,12 +107,8 @@ export const registerRemoteCommands = (program: Command): void => { process.exit(1); } - apiUrl = detectedUrl; - } else if (detectedUrl) { console.log(chalk.gray(`Found local server at ${detectedUrl}`)); apiUrl = detectedUrl; - } else if (options.token) { - apiUrl = 'http://localhost:2020'; } else { apiUrl = ( await inquirer.prompt<{ apiUrl: string }>([ @@ -146,7 +134,7 @@ export const registerRemoteCommands = (program: Command): void => { const name = options.as ?? deriveRemoteName(apiUrl); ConfigService.setActiveRemote(name); - await authenticate(apiUrl, options.token); + await authenticate(apiUrl, options.apiKey); const defaultRemote = await configService.getDefaultRemote(); @@ -166,7 +154,7 @@ export const registerRemoteCommands = (program: Command): void => { if (remotes.length === 0) { console.log('No remotes configured.'); - console.log("Use 'twenty remote add ' to add one."); + console.log("Use 'twenty remote add' to add one."); return; } diff --git a/packages/twenty-sdk/src/cli/operations/execute.ts b/packages/twenty-sdk/src/cli/operations/execute.ts index e90832dfe5..f9522bac5a 100644 --- a/packages/twenty-sdk/src/cli/operations/execute.ts +++ b/packages/twenty-sdk/src/cli/operations/execute.ts @@ -15,6 +15,7 @@ export type FunctionExecuteOptions = { remote?: string; payload?: Record; } & ( + | { preInstall: true } | { postInstall: true } | { functionUniversalIdentifier: string } | { functionName: string } @@ -38,6 +39,7 @@ const belongsToApplication = ( }; const resolveIdentifier = (options: FunctionExecuteOptions): string => { + if ('preInstall' in options) return 'pre install'; if ('postInstall' in options) return 'post install'; if ('functionUniversalIdentifier' in options) return options.functionUniversalIdentifier; @@ -90,6 +92,12 @@ const innerFunctionExecute = async ( ); const targetFunction = appFunctions.find((logicFunction) => { + if ('preInstall' in options && options.preInstall) { + return ( + logicFunction.universalIdentifier === + manifest.application.preInstallLogicFunctionUniversalIdentifier + ); + } if ('postInstall' in options && options.postInstall) { return ( logicFunction.universalIdentifier === diff --git a/packages/twenty-sdk/src/cli/operations/server-start.ts b/packages/twenty-sdk/src/cli/operations/server-start.ts index 9c96cb5724..b7b84b0985 100644 --- a/packages/twenty-sdk/src/cli/operations/server-start.ts +++ b/packages/twenty-sdk/src/cli/operations/server-start.ts @@ -14,25 +14,86 @@ import { checkServerHealth, detectLocalServer, } from '@/cli/utilities/server/detect-local-server'; -import { execSync, spawnSync } from 'node:child_process'; +import { execSync, spawn, spawnSync } from 'node:child_process'; +import chalk from 'chalk'; const HEALTH_POLL_INTERVAL_MS = 2000; const HEALTH_TIMEOUT_MS = 180 * 1000; +const MILESTONE_START = '==> START '; +const MILESTONE_DONE = '==> DONE'; const waitForHealthy = async (port: number): Promise => { const startTime = Date.now(); + const onProgress = (message: string) => + process.stdout.write(chalk.gray(message)); - while (Date.now() - startTime < HEALTH_TIMEOUT_MS) { - if (await checkServerHealth(port)) { - return true; + const logStream = spawn( + 'docker', + ['logs', '-f', '--since', '1s', CONTAINER_NAME], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + logStream.on('error', () => {}); + + let hasPendingStep = false; + + const handleLogLine = (line: string) => { + const trimmed = line.trim(); + const startIndex = trimmed.indexOf(MILESTONE_START); + const doneIndex = trimmed.indexOf(MILESTONE_DONE); + + if (startIndex !== -1) { + if (hasPendingStep) { + onProgress('Done\n'); + } + + const message = trimmed.slice(startIndex + MILESTONE_START.length); + + onProgress(`==> ${message}... `); + hasPendingStep = true; + } else if (doneIndex !== -1 && hasPendingStep) { + onProgress('Done\n'); + hasPendingStep = false; + } + }; + + let logBuffer = ''; + + const onData = (chunk: Buffer) => { + logBuffer += chunk.toString(); + + const lines = logBuffer.split('\n'); + + logBuffer = lines.pop() ?? ''; + lines.forEach(handleLogLine); + }; + + logStream.stdout?.on('data', onData); + logStream.stderr?.on('data', onData); + + try { + while (Date.now() - startTime < HEALTH_TIMEOUT_MS) { + if (await checkServerHealth(port)) { + if (hasPendingStep) { + onProgress('Done\n'); + } + + return true; + } + + await new Promise((resolve) => + setTimeout(resolve, HEALTH_POLL_INTERVAL_MS), + ); } - await new Promise((resolve) => - setTimeout(resolve, HEALTH_POLL_INTERVAL_MS), - ); - } + if (hasPendingStep) { + onProgress('Failed\n'); + } - return false; + return false; + } finally { + logStream.kill(); + } }; export type ServerStartOptions = { @@ -126,8 +187,6 @@ const innerServerStart = async ( } else { onProgress?.('Starting Twenty container...'); - const serverUrl = `http://localhost:${port}`; - const runResult = spawnSync( 'docker', [ @@ -135,14 +194,16 @@ const innerServerStart = async ( '-d', '--name', CONTAINER_NAME, - '-e', - `SERVER_URL=${serverUrl}`, '-p', - `${port}:3000`, + `${port}:${port}`, + '-e', + `NODE_PORT=${port}`, + '-e', + `SERVER_URL=http://localhost:${port}`, '-v', 'twenty-app-dev-data:/data/postgres', '-v', - 'twenty-app-dev-storage:/app/.local-storage', + 'twenty-app-dev-storage:/app/packages/twenty-server/.local-storage', IMAGE, ], { stdio: 'inherit' }, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts index 2ff6a63674..655ee24020 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts @@ -73,7 +73,7 @@ export class CheckServerOrchestratorStep { this.state.applyStepEvents([ { message: - 'Authentication failed. Run `yarn twenty remote add` to authenticate.', + 'Authentication failed. Run `yarn twenty remote add --local` to authenticate.', status: 'error', }, ]); diff --git a/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts b/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts index 0c859cef9b..8af3c39d78 100644 --- a/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts +++ b/packages/twenty-sdk/src/cli/utilities/server/docker-container.ts @@ -20,11 +20,13 @@ export const isContainerRunning = (): boolean => { export const getContainerPort = (): number => { try { const result = execSync( - `docker inspect -f '{{(index (index .NetworkSettings.Ports "3000/tcp") 0).HostPort}}' ${CONTAINER_NAME}`, + `docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' ${CONTAINER_NAME}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }, - ).trim(); + ); - return parseInt(result, 10) || DEFAULT_PORT; + const match = result.match(/^NODE_PORT=(\d+)$/m); + + return match ? parseInt(match[1], 10) : DEFAULT_PORT; } catch { return DEFAULT_PORT; } diff --git a/packages/twenty-sdk/src/sdk/get-public-asset-url.ts b/packages/twenty-sdk/src/sdk/get-public-asset-url.ts new file mode 100644 index 0000000000..db9a8fc46a --- /dev/null +++ b/packages/twenty-sdk/src/sdk/get-public-asset-url.ts @@ -0,0 +1,42 @@ +import { + DEFAULT_API_URL_NAME, + DEFAULT_APP_ACCESS_TOKEN_NAME, +} from 'twenty-shared/application'; + +const decodeTokenPayload = ( + token: string, +): { workspaceId: string; applicationId: string } => { + const payload = JSON.parse(atob(token.split('.')[1])); + + return { + workspaceId: payload.workspaceId, + applicationId: payload.applicationId, + }; +}; + +// Returns the public URL for a file in the app's public/ directory. +// Works in both logic functions and front components. +// The path is relative to the public/ folder (e.g. "images/logo.png"). +export const getPublicAssetUrl = (path: string): string => { + const apiUrl = process.env[DEFAULT_API_URL_NAME]; + const token = process.env[DEFAULT_APP_ACCESS_TOKEN_NAME]; + + if (!apiUrl || !token) { + throw new Error( + 'getPublicAssetUrl can only be called from within a logic function or front component', + ); + } + + const { workspaceId, applicationId } = decodeTokenPayload(token); + const withoutLeadingSlash = path.startsWith('/') ? path.slice(1) : path; + const withPublicPrefix = withoutLeadingSlash.startsWith('public/') + ? withoutLeadingSlash + : `public/${withoutLeadingSlash}`; + + const encodedPath = withPublicPrefix + .split('/') + .map(encodeURIComponent) + .join('/'); + + return `${apiUrl}/public-assets/${workspaceId}/${applicationId}/${encodedPath}`; +}; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index d626bdf613..b18b7e8160 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -35,6 +35,7 @@ export type { FrontComponentConfig, FrontComponentType, } from './front-component-config'; +export { getPublicAssetUrl } from './get-public-asset-url'; export { defineLogicFunction } from './logic-functions/define-logic-function'; export { definePostInstallLogicFunction } from './logic-functions/define-post-install-logic-function'; export { definePreInstallLogicFunction } from './logic-functions/define-pre-install-logic-function'; diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index 760fea5242..f19e9d2806 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -1,6 +1,6 @@ import { Logger } from '@nestjs/common'; -import { Command, CommandRunner } from 'nest-commander'; +import { Command, CommandRunner, Option } from 'nest-commander'; import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command'; import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command'; @@ -66,10 +66,35 @@ export class CronRegisterAllCommand extends CommandRunner { super(); } - async run(): Promise { - this.logger.log('Registering all background sync cron jobs...'); + private devMode = false; - const commands = [ + @Option({ + flags: '--dev-mode', + description: + 'Only register cron jobs relevant to app development (cron triggers, marketplace sync, version check, stale cleanup)', + required: false, + }) + parseDevMode(): boolean { + this.devMode = true; + + return true; + } + + private static readonly DEV_MODE_COMMANDS = new Set([ + 'CronTrigger', + 'MarketplaceCatalogSync', + 'ApplicationVersionCheck', + 'StaleRegistrationCleanup', + ]); + + async run(): Promise { + this.logger.log( + this.devMode + ? 'Registering app-dev cron jobs...' + : 'Registering all background sync cron jobs...', + ); + + const allCommands = [ { name: 'MessagingMessagesImport', command: this.messagingMessagesImportCronCommand, @@ -164,6 +189,12 @@ export class CronRegisterAllCommand extends CommandRunner { }, ]; + const commands = this.devMode + ? allCommands.filter(({ name }) => + CronRegisterAllCommand.DEV_MODE_COMMANDS.has(name), + ) + : allCommands; + let successCount = 0; let failureCount = 0; const failures: string[] = []; diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts index 8f899ce904..3d81553d2c 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts @@ -38,6 +38,7 @@ import { import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard'; export interface PageInfo { hasNextPage?: boolean; @@ -105,6 +106,11 @@ export abstract class RestApiBaseHandler { } roleId = userWorkspaceRoleId; + } else if ( + isApplicationAuthContext(authContext) && + isDefined(authContext.application.defaultRoleId) + ) { + roleId = authContext.application.defaultRoleId; } else { throw new PermissionsException( 'Authentication context is invalid',