twenty/packages/create-twenty-app/README.md
Paul Rastoin 4ea2e32366
Refactor twenty client sdk provisioning for logic function and front-component (#18544)
## 1. The `twenty-client-sdk` Package (Source of Truth)

The monorepo package at `packages/twenty-client-sdk` ships with:
- A **pre-built metadata client** (static, generated from a fixed
schema)
- A **stub core client** that throws at runtime (`CoreApiClient was not
generated...`)
- Both ESM (`.mjs`) and CJS (`.cjs`) bundles in `dist/`
- A `package.json` with proper `exports` map for
`twenty-client-sdk/core`, `twenty-client-sdk/metadata`, and
`twenty-client-sdk/generate`

## 2. Generation & Upload (Server-Side, at Migration Time)

**When**: `WorkspaceMigrationRunnerService.run()` executes after a
metadata schema change.

**What happens in `SdkClientGenerationService.generateAndStore()`**:
1. Copies the stub `twenty-client-sdk` package from the server's assets
(resolved via `SDK_CLIENT_PACKAGE_DIRNAME` — from
`dist/assets/twenty-client-sdk/` in production, or from `node_modules`
in dev)
2. Filters out `node_modules/` and `src/` during copy — only
`package.json` + `dist/` are kept (like an npm publish)
3. Calls `replaceCoreClient()` which uses `@genql/cli` to introspect the
**application-scoped** GraphQL schema and generates a real
`CoreApiClient`, then compiles it to ESM+CJS and overwrites
`dist/core.mjs` and `dist/core.cjs`
4. Archives the **entire package** (with `package.json` + `dist/`) into
`twenty-client-sdk.zip`
5. Uploads the single archive to S3 under
`FileFolder.GeneratedSdkClient`
6. Sets `isSdkLayerStale = true` on the `ApplicationEntity` in the
database

## 3. Invalidation Signal

The `isSdkLayerStale` boolean column on `ApplicationEntity` is the
invalidation mechanism:
- **Set to `true`** by `generateAndStore()` after uploading a new client
archive
- **Checked** by both logic function drivers before execution — if
`true`, they rebuild their local layer
- **Set back to `false`** by `markSdkLayerFresh()` after the driver has
successfully consumed the new archive

Default is `false` so existing applications without a generated client
aren't affected.

## 4a. Logic Functions — Local Driver

**`ensureSdkLayer()`** is called before every execution:
1. Checks if the local SDK layer directory exists AND `isSdkLayerStale`
is `false` → early return
2. Otherwise, cleans the local layer directory
3. Calls `downloadAndExtractToPackage()` which streams the zip from S3
directly to disk and extracts the full package into
`<tmpdir>/sdk/<workspaceId>-<appId>/node_modules/twenty-client-sdk/`
4. Calls `markSdkLayerFresh()` to set `isSdkLayerStale = false`

**At execution time**, `assembleNodeModules()` symlinks everything from
the deps layer's `node_modules/` **except** `twenty-client-sdk`, which
is symlinked from the SDK layer instead. This ensures the logic
function's `import ... from 'twenty-client-sdk/core'` resolves to the
generated client.

## 4b. Logic Functions — Lambda Driver

**`ensureSdkLayer()`** is called during `build()`:
1. Checks if `isSdkLayerStale` is `false` and an existing Lambda layer
ARN exists → early return
2. Otherwise, deletes all existing layer versions for this SDK layer
name
3. Calls `downloadArchiveBuffer()` to get the raw zip from S3 (no disk
extraction)
4. Calls `reprefixZipEntries()` which streams the zip entries into a
**new zip** with the path prefix
`nodejs/node_modules/twenty-client-sdk/` — this is the Lambda layer
convention path. All done in memory, no disk round-trip
5. Publishes the re-prefixed zip as a new Lambda layer via
`publishLayer()`
6. Calls `markSdkLayerFresh()`

**At function creation**, the Lambda is created with **two layers**:
`[depsLayerArn, sdkLayerArn]`. The SDK layer is listed last so it
overwrites the stub `twenty-client-sdk` from the deps layer (later
layers take precedence in Lambda's `/opt` merge).

## 5. Front Components

Front components are built by `app:build` with `twenty-client-sdk/core`
and `twenty-client-sdk/metadata` as **esbuild externals**. The stored
`.mjs` in S3 has unresolved bare import specifiers like `import {
CoreApiClient } from 'twenty-client-sdk/core'`.

SDK import resolution is split between the **frontend host** (fetching &
caching SDK modules) and the **Web Worker** (rewriting imports):

**Server endpoints**:
- `GET /rest/front-components/:id` —
`FrontComponentService.getBuiltComponentStream()` returns the **raw
`.mjs`** directly from file storage. No bundling, no SDK injection.
- `GET /rest/sdk-client/:applicationId/:moduleName` —
`SdkClientController` reads a single file (e.g. `dist/core.mjs`) from
the generated SDK archive via
`SdkClientGenerationService.readFileFromArchive()` and serves it as
JavaScript.

**Frontend host** (`FrontComponentRenderer` in `twenty-front`):
1. Queries `FindOneFrontComponent` which returns `applicationId`,
`builtComponentChecksum`, `usesSdkClient`, and `applicationTokenPair`
2. If `usesSdkClient` is `true`, renders
`FrontComponentRendererWithSdkClient` which calls the
`useApplicationSdkClient` hook
3. `useApplicationSdkClient({ applicationId, accessToken })` checks the
Jotai atom family cache for existing blob URLs. On cache miss, fetches
both SDK modules from `GET /rest/sdk-client/:applicationId/core` and
`/metadata`, creates **blob URLs** for each, and stores them in the atom
family
4. Once the blob URLs are cached, passes them as `sdkClientUrls`
(already blob URLs, not server URLs) to `SharedFrontComponentRenderer` →
`FrontComponentWorkerEffect` → worker's `render()` call via
`HostToWorkerRenderContext`

**Worker** (`remote-worker.ts` in `twenty-sdk`):
1. Fetches the raw component `.mjs` source as text
2. If `sdkClientUrls` are provided and the source contains SDK import
specifiers (`twenty-client-sdk/core`, `twenty-client-sdk/metadata`),
**rewrites** the bare specifiers to the blob URLs received from the host
(e.g. `'twenty-client-sdk/core'` → `'blob:...'`)
3. Creates a blob URL for the rewritten source and `import()`s it
4. Revokes only the component blob URL after the module is loaded — the
SDK blob URLs are owned and managed by the host's Jotai cache

This approach eliminates server-side esbuild bundling on every request,
caches SDK modules per application in the frontend, and keeps the
worker's job to a simple string rewrite.

## Summary Diagram

```
app:build (SDK)
  └─ twenty-client-sdk stub (metadata=real, core=stub)
       │
       ▼
WorkspaceMigrationRunnerService.run()
  └─ SdkClientGenerationService.generateAndStore()
       ├─ Copy stub package (package.json + dist/)
       ├─ replaceCoreClient() → regenerate core.mjs/core.cjs
       ├─ Zip entire package → upload to S3
       └─ Set isSdkLayerStale = true
              │
     ┌────────┴────────────────────┐
     ▼                             ▼
Logic Functions               Front Components
     │                             │
     ├─ Local Driver               ├─ GET /rest/sdk-client/:appId/core
     │   └─ downloadAndExtract     │    → core.mjs from archive
     │      → symlink into         │
     │        node_modules         ├─ Host (useApplicationSdkClient)
     │                             │    ├─ Fetch SDK modules
     └─ Lambda Driver              │    ├─ Create blob URLs
         └─ downloadArchiveBuffer  │    └─ Cache in Jotai atom family
            → reprefixZipEntries   │
            → publish as Lambda    ├─ GET /rest/front-components/:id
              layer                │    → raw .mjs (no bundling)
                                   │
                                   └─ Worker (browser)
                                        ├─ Fetch component .mjs
                                        ├─ Rewrite imports → blob URLs
                                        └─ import() rewritten source
```

## Next PR
- Estimate perf improvement by implementing a redis caching for front
component client storage ( we don't even cache front comp initially )
- Implem frontent blob invalidation sse event from server

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-24 18:10:25 +00:00

7.5 KiB
Raw Blame History

Twenty logo

Create Twenty App

NPM version License Join the community on Discord

Create Twenty App is the official scaffolding CLI for building apps on top of Twenty CRM. It sets up a readytorun project that works seamlessly with the twenty-sdk.

  • Zeroconfig 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)

Quick start

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

Scaffolding modes

Control which example files are included when creating a new app:

Flag Behavior
-e, --exhaustive (default) Creates all example files
-m, --minimal Creates only core files (application-config.ts and default-role.ts)
# Default: all examples included
npx create-twenty-app@latest my-app

# Minimal: only core files
npx create-twenty-app@latest my-app -m

What gets scaffolded

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). You can also manage it manually:

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).

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):

npx create-twenty-app@latest my-app --port 3000

Next steps

  • Run yarn twenty help to see all available commands.
  • Use yarn twenty remote add --local 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:

# 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:

git clone https://github.com/twentyhq/twenty.git
cd twenty
git checkout -b feature/my-awesome-app

Our team reviews contributions for quality, security, and reusability before merging.

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 --local.
  • Types not generated: ensure yarn twenty dev is running — it auto-generates the typed client.

Contributing