Skip to main content

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

yarn add axios
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:
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:
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:
FunctionDescription
appBuildBuild the app and optionally pack a tarball
appDeployUpload a tarball to the server
appInstallInstall the app on the active workspace
appUninstallUninstall 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:
yarn test
Or in watch mode during development:
yarn test:watch

Type checking

You can also run type checking on your app without running tests:
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:
# 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:
  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
.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.