packages/twenty-docs/developers/extend/apps/cli-and-testing.mdx
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:
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.public/, it is synced to the server automatically. No restart needed.yarn twenty build bundles all public assets into the distribution output.getPublicAssetUrlUse 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:
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk/define';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
const invoiceUrl = getPublicAssetUrl('templates/invoice.png');
// Fetch the file content (no auth required — public endpoint)
const response = await fetch(invoiceUrl);
const buffer = await response.arrayBuffer();
return { logoUrl, size: buffer.byteLength };
};
export default defineLogicFunction({
universalIdentifier: 'a1b2c3d4-...',
name: 'send-invoice',
description: 'Sends an invoice with the app logo',
timeoutSeconds: 10,
handler,
});
In a front component:
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
export default defineFrontComponent(() => {
const logoUrl = getPublicAssetUrl('logo.png');
return ;
});
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.
You can install and use any npm package in your app. Both logic functions and front components are bundled with esbuild, which inlines all dependencies into the output — no node_modules are needed at runtime.
yarn add axios
Then import it in your code:
import { defineLogicFunction } from 'twenty-sdk/define';
import axios from 'axios';
const handler = async (): Promise<any> => {
const { data } = await axios.get('https://api.example.com/data');
return { data };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-data',
description: 'Fetches data from an external API',
timeoutSeconds: 10,
handler,
});
The same works for front components:
import { defineFrontComponent } from 'twenty-sdk/define';
import { format } from 'date-fns';
const DateWidget = () => {
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'date-widget',
component: DateWidget,
});
The build step 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.
The SDK provides programmatic APIs that let you build, deploy, install, and uninstall your app from test code. Combined with Vitest and the typed API clients, you can write integration tests that verify your app works end-to-end against a real Twenty server.
The scaffolded app already includes Vitest. If you set it up manually, install the dependencies:
yarn add -D vitest vite-tsconfig-paths
Create a vitest.config.ts at the root of your app:
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:
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),
);
});
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.
Here is a full example that builds, deploys, and installs the app, then verifies it appears in the workspace:
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();
});
});
Make sure your local Twenty server is running, then:
yarn test
Or in watch mode during development:
yarn test:watch
You can also run type checking on your app without running tests:
yarn twenty typecheck
This runs tsc --noEmit and reports any type errors.
Beyond dev, build, add, and typecheck, the CLI provides commands for executing functions, viewing logs, and managing app installations.
yarn twenty exec)Run a logic function manually without triggering it via HTTP, cron, or database event:
# 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 the post-install function
yarn twenty exec --postInstall
yarn twenty logs)Stream execution logs for your app's logic functions:
# 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
yarn twenty uninstall)Remove your app from the active workspace:
yarn twenty uninstall
# Skip the confirmation prompt
yarn twenty uninstall --yes
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.
# Add a new remote (opens a browser for OAuth login)
yarn twenty remote add
# Connect to a local Twenty server (auto-detects port 2020 or 3000)
yarn twenty remote add --local
# Add a remote non-interactively (useful for CI)
yarn twenty remote add --api-url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
# List all configured remotes
yarn twenty remote list
# Switch the active remote
yarn twenty remote switch <name>
Your credentials are stored in ~/.twenty/config.json.
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:
twentyhq/twenty/.github/actions/spawn-twenty-docker-image actionyarn install --immutableyarn test with TWENTY_API_URL and TWENTY_API_KEY injected from the action outputsname: 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.