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:
src/logic-functions/send-invoice.ts
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:
src/front-components/company-card.tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
export default defineFrontComponent(() => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="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, which inlines all dependencies into the output — no node_modules are needed at runtime.
Installing a package
Then import it in your code:
src/logic-functions/fetch-data.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import axios from 'axios';
const handler = async (): Promise<any> => {
const { data } = await axios.get('https://api.example.com/data');
return { data };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-data',
description: 'Fetches data from an external API',
timeoutSeconds: 10,
handler,
});
The same works for front components:
src/front-components/chart.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { format } from 'date-fns';
const DateWidget = () => {
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'date-widget',
component: DateWidget,
});
How bundling works
The build step uses esbuild to produce a single self-contained file per logic function and per front component. 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.
Testing your app
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.
Setup
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:
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:
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:
Or in watch mode during development:
Type checking
You can also run type checking on your app without running tests:
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:
# 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
Viewing function logs (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
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:
yarn twenty uninstall
# Skip the confirmation prompt
yarn twenty uninstall --yes
Managing remotes
A remote is a Twenty server that your app connects to. During setup, the scaffolder creates one for you automatically. You can add more remotes or switch between them at any time.
# 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.
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:
- Checks out your code
- Spins up a temporary Twenty server using the
twentyhq/twenty/.github/actions/spawn-twenty-docker-image action
- Installs dependencies with
yarn install --immutable
- Runs
yarn test with TWENTY_API_URL and TWENTY_API_KEY injected from the action outputs
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.